diff --git a/cmd/initializer.go b/cmd/initializer.go index 636adf2..dff369e 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -6,11 +6,14 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" sourcev1 "github.com/fluxcd/source-controller/api/v1" + openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/kcp-dev/logicalcluster/v3" "github.com/kcp-dev/multicluster-provider/initializingworkspaces" pmcontext "github.com/platform-mesh/golang-commons/context" "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/rest" @@ -98,7 +101,22 @@ var initializerCmd = &cobra.Command{ initializerCfg.IDP.AdditionalRedirectURLs = []string{} } - if err := controller.NewLogicalClusterReconciler(log, orgClient, initializerCfg, inClusterClient, mgr). + if initializerCfg.FGA.Target == "" { + log.Error().Msg("FGA target is empty; set fga-target in configuration") + os.Exit(1) + } + + // gRPC client initialization: mTLS is handled by the service mesh (Istio) + conn, err := grpc.NewClient(initializerCfg.FGA.Target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Error().Err(err).Msg("unable to create OpenFGA gRPC client") + os.Exit(1) + } + defer func() { _ = conn.Close() }() + + fga := openfgav1.NewOpenFGAServiceClient(conn) + + if err := controller.NewLogicalClusterReconciler(log, orgClient, initializerCfg, inClusterClient, mgr, fga). SetupWithManager(mgr, defaultCfg); err != nil { setupLog.Error(err, "unable to create controller", "controller", "LogicalCluster") os.Exit(1) diff --git a/data/coreModule.fga b/data/coreModule.fga index d85d09e..dcb3b3d 100644 --- a/data/coreModule.fga +++ b/data/coreModule.fga @@ -6,24 +6,90 @@ type role relations define assignee: [user,user:*] -type account +type core_platform-mesh_io_account relations + define parent: [core_platform-mesh_io_account] - define parent: [account] - define owner: [role#assignee] - define member: [role#assignee] or owner + define owner: [role#assignee] or owner from parent + define member: [role#assignee] or owner or member from parent - define get: member or get from parent - define update: member or update from parent - define delete: owner or delete from parent + define get: member + define update: member + define patch: member + define delete: owner define create_core_platform-mesh_io_accounts: member define list_core_platform-mesh_io_accounts: member define watch_core_platform-mesh_io_accounts: member # org and account specific - define watch: member or watch from parent + define watch: member - # org specific - define create: member or create from parent - define list: member or list from parent \ No newline at end of file + define create_core_namespaces: member + define list_core_namespaces: member + define watch_core_namespaces: member + + define create_core_platform-mesh_io_accountinfos: member + define list_core_platform-mesh_io_accountinfos: member + define watch_core_platform-mesh_io_accountinfos: member + + define create_apis_kcp_io_apibindings: owner + define list_apis_kcp_io_apibindings: member + define watch_apis_kcp_io_apibindings: member + + # IAM specific + define manage_iam_roles: owner + define get_iam_roles: member + define get_iam_users: member + +type core_namespace + relations + define parent: [core_platform-mesh_io_account] + + define member: member from parent + define owner: owner from parent + + define get: member + define watch: member + + define update: member + define patch: member + define delete: member + + # IAM specific + define manage_iam_roles: owner + define get_iam_roles: member + define get_iam_users: member + +type core_platform-mesh_io_accountinfo + relations + define parent: [core_platform-mesh_io_account] + + define member: member from parent + define owner: owner from parent + + define get: member + define watch: member + + # IAM specific + define manage_iam_roles: owner + define get_iam_roles: member + define get_iam_users: member + +type apis_kcp_io_apibinding + relations + define parent: [core_platform-mesh_io_account] + + define member: member from parent + define owner: owner from parent + + define get: member + define watch: member + define update: owner + define patch: owner + define delete: owner + + # IAM specific + define manage_iam_roles: owner + define get_iam_roles: member + define get_iam_users: member diff --git a/go.mod b/go.mod index 281f103..01a83de 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/kcp-dev/multicluster-provider v0.2.0 github.com/openfga/api/proto v0.0.0-20250909173124-0ac19aac54f2 github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251003203216-7c0d09a1cc5a - github.com/platform-mesh/account-operator v0.5.9 + github.com/platform-mesh/account-operator v0.5.5-0.20251017120838-b8c73d0b347e github.com/platform-mesh/golang-commons v0.7.4 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.10.1 diff --git a/go.sum b/go.sum index e439fcf..2715d94 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/platform-mesh/account-operator v0.5.9 h1:7Taz/4ZeOaHcQbdW5LPNFXv8PVCg0KyEmxGbe0oiQPg= -github.com/platform-mesh/account-operator v0.5.9/go.mod h1:3wJaOforqD5TBUDRzhMA8EmWCIDpktR80r210g5hLnQ= +github.com/platform-mesh/account-operator v0.5.5-0.20251017120838-b8c73d0b347e h1:eG3p5jn92yKjXBfkU4E+t/qvfeqZNnYvjwlX3aFknyc= +github.com/platform-mesh/account-operator v0.5.5-0.20251017120838-b8c73d0b347e/go.mod h1:Kg2hxWCRggF86d9zrjhHGvEo42B+sfL+Go2PVBs9uvo= github.com/platform-mesh/golang-commons v0.7.4 h1:ZIY9ExAZ+BbH1xcn96zw2wR8rlsDST7Soow8yaHG2Mc= github.com/platform-mesh/golang-commons v0.7.4/go.mod h1:GJe0jJcS9hfT7ajo7sbOe5p2Uw0GuVLeQhZEffKM9os= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/config/config.go b/internal/config/config.go index b1dc419..502f9ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,7 +10,10 @@ type InviteConfig struct { // Config struct to hold the app config type Config struct { FGA struct { - Target string `mapstructure:"fga-target"` + Target string `mapstructure:"fga-target"` + ObjectType string `mapstructure:"fga-object-type" default:"core_platform-mesh_io_account"` + ParentRelation string `mapstructure:"fga-parent-relation" default:"parent"` + CreatorRelation string `mapstructure:"fga-creator-relation" default:"owner"` } `mapstructure:",squash"` APIExportEndpointSliceName string `mapstructure:"api-export-endpoint-slice-name"` CoreModulePath string `mapstructure:"core-module-path"` diff --git a/internal/controller/initializer_controller.go b/internal/controller/initializer_controller.go index 5741c08..54e4b62 100644 --- a/internal/controller/initializer_controller.go +++ b/internal/controller/initializer_controller.go @@ -4,6 +4,7 @@ import ( "context" kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + openfgav1 "github.com/openfga/api/proto/openfga/v1" platformeshconfig "github.com/platform-mesh/golang-commons/config" "github.com/platform-mesh/golang-commons/controller/lifecycle/builder" lifecyclecontrollerruntime "github.com/platform-mesh/golang-commons/controller/lifecycle/multicluster" @@ -26,11 +27,12 @@ type LogicalClusterReconciler struct { lifecycle *lifecyclecontrollerruntime.LifecycleManager } -func NewLogicalClusterReconciler(log *logger.Logger, orgClient client.Client, cfg config.Config, inClusterClient client.Client, mgr mcmanager.Manager) *LogicalClusterReconciler { +func NewLogicalClusterReconciler(log *logger.Logger, orgClient client.Client, cfg config.Config, inClusterClient client.Client, mgr mcmanager.Manager, fga openfgav1.OpenFGAServiceClient) *LogicalClusterReconciler { return &LogicalClusterReconciler{ log: log, lifecycle: builder.NewBuilder("logicalcluster", "LogicalClusterReconciler", []lifecyclesubroutine.Subroutine{ subroutine.NewWorkspaceInitializer(orgClient, cfg, mgr), + subroutine.NewWorkspaceFGASubroutine(mgr, fga, cfg.FGA.ObjectType, cfg.FGA.ParentRelation, cfg.FGA.CreatorRelation), subroutine.NewWorkspaceAuthConfigurationSubroutine(orgClient, inClusterClient, cfg), subroutine.NewRealmSubroutine(inClusterClient, &cfg, cfg.BaseDomain), subroutine.NewInviteSubroutine(orgClient, mgr), diff --git a/internal/subroutine/authorization_model.go b/internal/subroutine/authorization_model.go index f6a5a49..d2baf46 100644 --- a/internal/subroutine/authorization_model.go +++ b/internal/subroutine/authorization_model.go @@ -33,7 +33,7 @@ const ( var ( privilegedGroupVersions = []string{"rbac.authorization.k8s.io/v1"} - groupVersions = []string{"authentication.k8s.io/v1", "authorization.k8s.io/v1", "v1"} + groupVersions = []string{"authentication.k8s.io/v1", "authorization.k8s.io/v1", "v1"} privilegedTemplate = template.Must(template.New("model").Parse(`module internal_core_types_{{ .Name }} @@ -56,13 +56,15 @@ extend type core_namespace type {{ .Group }}_{{ .Singular }} relations define parent: [{{ if eq .Scope "Namespaced" }}core_namespace{{ else }}core_platform-mesh_io_account{{ end }}] - define member: [role#assignee] or owner or member from parent - define owner: [role#assignee] or owner from parent + define member: member from parent + define owner: owner from parent define get: member define update: owner define delete: owner define patch: owner + define statusPatch: member + define statusUpdate: member define watch: member define manage_iam_roles: owner @@ -216,6 +218,13 @@ func (a *authorizationModelSubroutine) Process(ctx context.Context, instance run } + // DEBUG: Log the model being written + modelJSON, _ := protojson.Marshal(authorizationModel) + modelDSL, err := language.TransformJSONStringToDSL(string(modelJSON)) + if err == nil && modelDSL != nil { + log.Info().Str("store", store.Name).Str("model", *modelDSL).Msg("Writing authorization model") + } + res, err := a.fga.WriteAuthorizationModel(ctx, &openfgav1.WriteAuthorizationModelRequest{ StoreId: store.Status.StoreID, TypeDefinitions: authorizationModel.TypeDefinitions, diff --git a/internal/subroutine/authorization_model_generation.go b/internal/subroutine/authorization_model_generation.go index 4f87200..46a1ea9 100644 --- a/internal/subroutine/authorization_model_generation.go +++ b/internal/subroutine/authorization_model_generation.go @@ -58,9 +58,12 @@ extend type core_namespace type {{ .Group }}_{{ .Singular }} relations define parent: [{{ if eq .Scope "Namespaced" }}core_namespace{{ else }}core_platform-mesh_io_account{{ end }}] - define member: [role#assignee] or owner or member from parent - define owner: [role#assignee] or owner from parent - + define member: member from parent + define owner: owner from parent + + define statusUpdate: member + define statusPatch: member + define get: member define update: member define delete: member @@ -70,7 +73,6 @@ type {{ .Group }}_{{ .Singular }} define manage_iam_roles: owner define get_iam_roles: member define get_iam_users: member - `)) type modelInput struct { diff --git a/internal/subroutine/authorization_model_test.go b/internal/subroutine/authorization_model_test.go index ea15cb9..39941c9 100644 --- a/internal/subroutine/authorization_model_test.go +++ b/internal/subroutine/authorization_model_test.go @@ -73,10 +73,12 @@ type core_namespace define get_iam_roles: member define get_iam_users: member define manage_iam_roles: owner - define member: [role#assignee] or owner or member from parent - define owner: [role#assignee] or owner from parent + define member: member from parent + define owner: owner from parent define parent: [core_platform-mesh_io_account] define patch: member + define statusPatch: member + define statusUpdate: member define update: member define watch: member ` @@ -220,7 +222,7 @@ func TestAuthorizationModelProcess(t *testing.T) { Contents: extensionModel, }, { - Name: "internal_core_types_namespaces.fga", + Name: "internal_core_types_namespaces.fga", Contents: `module namespaces extend type core_platform-mesh_io_account @@ -232,13 +234,15 @@ extend type core_platform-mesh_io_account type core_namespace relations define parent: [core_platform-mesh_io_account] - define member: [role#assignee] or owner or member from parent - define owner: [role#assignee] or owner from parent + define member: member from parent + define owner: owner from parent define get: member define update: member define delete: member define patch: member + define statusPatch: member + define statusUpdate: member define watch: member define manage_iam_roles: owner diff --git a/internal/subroutine/mocks/mock_StatusWriter.go b/internal/subroutine/mocks/mock_StatusWriter.go new file mode 100644 index 0000000..a82be26 --- /dev/null +++ b/internal/subroutine/mocks/mock_StatusWriter.go @@ -0,0 +1,107 @@ +// Code generated manually for testing purposes. + +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// MockStatusWriter is a mock implementation of client.StatusWriter +type MockStatusWriter struct { + mock.Mock +} + +func NewMockStatusWriter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockStatusWriter { + m := &MockStatusWriter{} + m.Test(t) + t.Cleanup(func() { m.AssertExpectations(t) }) + return m +} + +func (m *MockStatusWriter) EXPECT() *MockStatusWriter_Expecter { + return &MockStatusWriter_Expecter{mock: &m.Mock} +} + +type MockStatusWriter_Expecter struct { + mock *mock.Mock +} + +func (m *MockStatusWriter) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + args := m.Called(ctx, obj, subResource, opts) + return args.Error(0) +} + +func (m *MockStatusWriter_Expecter) Create(ctx interface{}, obj interface{}, subResource interface{}, opts ...interface{}) *MockStatusWriter_Create_Call { + return &MockStatusWriter_Create_Call{Call: m.mock.On("Create", ctx, obj, subResource, opts)} +} + +type MockStatusWriter_Create_Call struct { + *mock.Call +} + +func (c *MockStatusWriter_Create_Call) Return(_a0 error) *MockStatusWriter_Create_Call { + c.Call.Return(_a0) + return c +} + +func (c *MockStatusWriter_Create_Call) RunAndReturn(run func(context.Context, client.Object, client.Object, ...client.SubResourceCreateOption) error) *MockStatusWriter_Create_Call { + c.Call.Return(run) + return c +} + +func (m *MockStatusWriter) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + args := m.Called(ctx, obj, opts) + return args.Error(0) +} + +func (m *MockStatusWriter_Expecter) Update(ctx interface{}, obj interface{}, opts ...interface{}) *MockStatusWriter_Update_Call { + return &MockStatusWriter_Update_Call{Call: m.mock.On("Update", append([]interface{}{ctx, obj}, opts...)...)} +} + +type MockStatusWriter_Update_Call struct { + *mock.Call +} + +func (c *MockStatusWriter_Update_Call) Return(_a0 error) *MockStatusWriter_Update_Call { + c.Call.Return(_a0) + return c +} + +func (c *MockStatusWriter_Update_Call) RunAndReturn(run func(context.Context, client.Object, ...client.SubResourceUpdateOption) error) *MockStatusWriter_Update_Call { + c.Call.Return(run) + return c +} + +func (c *MockStatusWriter_Update_Call) Once() *MockStatusWriter_Update_Call { + c.Call.Once() + return c +} + +func (m *MockStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + args := m.Called(ctx, obj, patch, opts) + return args.Error(0) +} + +func (m *MockStatusWriter_Expecter) Patch(ctx interface{}, obj interface{}, patch interface{}, opts ...interface{}) *MockStatusWriter_Patch_Call { + return &MockStatusWriter_Patch_Call{Call: m.mock.On("Patch", ctx, obj, patch, opts)} +} + +type MockStatusWriter_Patch_Call struct { + *mock.Call +} + +func (c *MockStatusWriter_Patch_Call) Return(_a0 error) *MockStatusWriter_Patch_Call { + c.Call.Return(_a0) + return c +} + +func (c *MockStatusWriter_Patch_Call) RunAndReturn(run func(context.Context, client.Object, client.Patch, ...client.SubResourcePatchOption) error) *MockStatusWriter_Patch_Call { + c.Call.Return(run) + return c +} diff --git a/internal/subroutine/store_test.go b/internal/subroutine/store_test.go index ceba692..94cc976 100644 --- a/internal/subroutine/store_test.go +++ b/internal/subroutine/store_test.go @@ -67,6 +67,21 @@ func TestProcess(t *testing.T) { }, nil) }, }, + { + name: "should fail if get store by ID fails", + store: &v1alpha1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "store", + }, + Status: v1alpha1.StoreStatus{ + StoreID: "id", + }, + }, + fgaMocks: func(fga *mocks.MockOpenFGAServiceClient) { + fga.EXPECT().GetStore(mock.Anything, &openfgav1.GetStoreRequest{StoreId: "id"}).Return(nil, errors.New("boom")) + }, + expectError: true, + }, { name: "should verify the store if .status.storeId is set", store: &v1alpha1.Store{ diff --git a/internal/subroutine/tuples_test.go b/internal/subroutine/tuples_test.go index 4ade6c4..bb68194 100644 --- a/internal/subroutine/tuples_test.go +++ b/internal/subroutine/tuples_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" "github.com/kcp-dev/logicalcluster/v3" "github.com/platform-mesh/security-operator/api/v1alpha1" "github.com/platform-mesh/security-operator/internal/subroutine" @@ -479,3 +480,68 @@ func TestTupleFinalizationWithStore(t *testing.T) { }) } } + +func TestTupleFinalizationWithAuthorizationModel_Errors(t *testing.T) { + t.Run("cluster from context error", func(t *testing.T) { + fga := mocks.NewMockOpenFGAServiceClient(t) + manager := mocks.NewMockManager(t) + // Simulate failure to get cluster from context + manager.EXPECT().ClusterFromContext(mock.Anything).Return(nil, errors.New("cluster ctx error")) + + sub := subroutine.NewTupleSubroutine(fga, manager) + am := &v1alpha1.AuthorizationModel{} + + ctx := mccontext.WithCluster(context.Background(), string(logicalcluster.Name("a"))) + _, opErr := sub.Finalize(ctx, am) + assert.NotNil(t, opErr) + }) + + t.Run("logicalcluster get error", func(t *testing.T) { + fga := mocks.NewMockOpenFGAServiceClient(t) + manager := mocks.NewMockManager(t) + cluster := mocks.NewMockCluster(t) + k8sClient := mocks.NewMockClient(t) + + manager.EXPECT().ClusterFromContext(mock.Anything).Return(cluster, nil) + cluster.EXPECT().GetClient().Return(k8sClient) + // First Get tries to fetch the LogicalCluster named "cluster" + k8sClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(errors.New("get lc error")).Once() + + sub := subroutine.NewTupleSubroutine(fga, manager) + am := &v1alpha1.AuthorizationModel{} + ctx := mccontext.WithCluster(context.Background(), string(logicalcluster.Name("a"))) + _, opErr := sub.Finalize(ctx, am) + assert.NotNil(t, opErr) + }) + + t.Run("store get error", func(t *testing.T) { + fga := mocks.NewMockOpenFGAServiceClient(t) + manager := mocks.NewMockManager(t) + cluster := mocks.NewMockCluster(t) + k8sClient := mocks.NewMockClient(t) + + // AuthorizationModel with StoreRef set so we try to lookup the store + am := &v1alpha1.AuthorizationModel{} + am.Spec.StoreRef.Name = "store" + am.Spec.StoreRef.Path = "path" + + manager.EXPECT().ClusterFromContext(mock.Anything).Return(cluster, nil) + cluster.EXPECT().GetClient().Return(k8sClient) + // First Get returns LogicalCluster successfully + k8sClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ client.ObjectKey, o client.Object, _ ...client.GetOption) error { + // Populate lc annotation used in code path + if lc, ok := o.(*kcpcorev1alpha1.LogicalCluster); ok { + lc.Annotations = map[string]string{"kcp.io/cluster": "path"} + } + return nil + }).Once() + + // Second: attempt to fetch Store from storeCtx must fail + k8sClient.EXPECT().Get(mock.Anything, types.NamespacedName{Name: "store"}, mock.Anything).Return(errors.New("get store error")).Once() + + sub := subroutine.NewTupleSubroutine(fga, manager) + ctx := mccontext.WithCluster(context.Background(), string(logicalcluster.Name("a"))) + _, opErr := sub.Finalize(ctx, am) + assert.NotNil(t, opErr) + }) +} diff --git a/internal/subroutine/workspace_authorization_test.go b/internal/subroutine/workspace_authorization_test.go index b32a664..bfd1baf 100644 --- a/internal/subroutine/workspace_authorization_test.go +++ b/internal/subroutine/workspace_authorization_test.go @@ -57,6 +57,23 @@ func TestWorkspaceAuthSubroutine_Process(t *testing.T) { expectError: false, expectedResult: ctrl.Result{}, }, + { + name: "error - domain CA lookup enabled but secret get fails", + logicalCluster: &kcpv1alpha1.LogicalCluster{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "kcp.io/path": "root:orgs:test-workspace", + }, + }, + }, + cfg: config.Config{BaseDomain: "test.domain", GroupClaim: "groups", UserClaim: "email", DomainCALookup: true}, + setupMocks: func(m *mocks.MockClient) { + m.EXPECT().Get(mock.Anything, types.NamespacedName{Name: "domain-certificate-ca", Namespace: "platform-mesh-system"}, mock.Anything, mock.Anything). + Return(errors.New("secret get failed")).Once() + }, + expectError: true, + expectedResult: ctrl.Result{}, + }, { name: "success - update existing WorkspaceAuthenticationConfiguration", logicalCluster: &kcpv1alpha1.LogicalCluster{ diff --git a/internal/subroutine/workspace_fga.go b/internal/subroutine/workspace_fga.go new file mode 100644 index 0000000..0d00221 --- /dev/null +++ b/internal/subroutine/workspace_fga.go @@ -0,0 +1,156 @@ +package subroutine + +import ( + "context" + "fmt" + "regexp" + "strings" + + kcpv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + accountsv1alpha1 "github.com/platform-mesh/account-operator/api/v1alpha1" + "github.com/platform-mesh/account-operator/pkg/subroutines/accountinfo" + "github.com/platform-mesh/golang-commons/controller/lifecycle/runtimeobject" + lifecyclesubroutine "github.com/platform-mesh/golang-commons/controller/lifecycle/subroutine" + "github.com/platform-mesh/golang-commons/errors" + "github.com/platform-mesh/golang-commons/fga/helpers" + apierrors "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" +) + +type workspaceFGASubroutine struct { + mgr mcmanager.Manager + fga openfgav1.OpenFGAServiceClient + fgaObjectType string + fgaParentRel string + fgaCreatorRel string +} + +func NewWorkspaceFGASubroutine(mgr mcmanager.Manager, fga openfgav1.OpenFGAServiceClient, objectType, parentRel, creatorRel string) *workspaceFGASubroutine { + return &workspaceFGASubroutine{ + mgr: mgr, + fga: fga, + fgaObjectType: objectType, + fgaParentRel: parentRel, + fgaCreatorRel: creatorRel, + } +} + +var _ lifecyclesubroutine.Subroutine = &workspaceFGASubroutine{} + +func (w *workspaceFGASubroutine) GetName() string { return "WorkspaceFGA" } + +func (w *workspaceFGASubroutine) Finalizers(_ runtimeobject.RuntimeObject) []string { return nil } + +func (w *workspaceFGASubroutine) Finalize(ctx context.Context, _ runtimeobject.RuntimeObject) (ctrl.Result, errors.OperatorError) { + return ctrl.Result{}, nil +} + +func (w *workspaceFGASubroutine) Process(ctx context.Context, instance runtimeobject.RuntimeObject) (ctrl.Result, errors.OperatorError) { + if w.fga == nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("OpenFGA client is nil"), false, false) + } + + if _, ok := instance.(*kcpv1alpha1.LogicalCluster); !ok { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unexpected instance type %T", instance), false, false) + } + + clusterRef, 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) + } + workspaceClient := clusterRef.GetClient() + + accountInfo := &accountsv1alpha1.AccountInfo{} + if err := workspaceClient.Get(ctx, client.ObjectKey{Name: accountinfo.DefaultAccountInfoName}, accountInfo); err != nil { + if apierrors.IsNotFound(err) { + // AccountInfo not created yet by workspace_initializer, requeue immediately + return ctrl.Result{RequeueAfter: 1}, nil + } + // Other errors (permissions, network, etc) should be surfaced + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("failed to get AccountInfo: %w", err), true, true) + } + if accountInfo.Spec.Account.Name == "" || accountInfo.Spec.Account.OriginClusterId == "" || accountInfo.Spec.FGA.Store.Id == "" { + return ctrl.Result{RequeueAfter: 1}, nil + } + + // Parent relation for non-org accounts + if accountInfo.Spec.ParentAccount != nil { + parent := accountInfo.Spec.ParentAccount + if err := w.writeTuple(ctx, accountInfo.Spec.FGA.Store.Id, &openfgav1.TupleKey{ + User: fmt.Sprintf("%s:%s/%s", w.fgaObjectType, parent.OriginClusterId, parent.Name), + Relation: w.fgaParentRel, + Object: fmt.Sprintf("%s:%s/%s", w.fgaObjectType, accountInfo.Spec.Account.OriginClusterId, accountInfo.Spec.Account.Name), + }); err != nil { + return ctrl.Result{}, err + } + } + + // Owner/creator relations: write only once using creator from AccountInfo + if accountInfo.Spec.Creator != nil && *accountInfo.Spec.Creator != "" && !accountInfo.Status.CreatorTupleWritten { + creator := *accountInfo.Spec.Creator + normalized := formatUser(creator) + if !validateCreator(normalized) { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("creator string is in the protected service account prefix range"), false, false) + } + if err := w.writeTuple(ctx, accountInfo.Spec.FGA.Store.Id, &openfgav1.TupleKey{ + User: fmt.Sprintf("user:%s", normalized), + Relation: "assignee", + Object: fmt.Sprintf("role:%s/%s/%s/owner", w.fgaObjectType, accountInfo.Spec.Account.OriginClusterId, accountInfo.Spec.Account.Name), + }); err != nil { + return ctrl.Result{}, err + } + if err := w.writeTuple(ctx, accountInfo.Spec.FGA.Store.Id, &openfgav1.TupleKey{ + User: fmt.Sprintf("role:%s/%s/%s/owner#assignee", w.fgaObjectType, accountInfo.Spec.Account.OriginClusterId, accountInfo.Spec.Account.Name), + Relation: w.fgaCreatorRel, + Object: fmt.Sprintf("%s:%s/%s", w.fgaObjectType, accountInfo.Spec.Account.OriginClusterId, accountInfo.Spec.Account.Name), + }); err != nil { + return ctrl.Result{}, err + } + + // Mark creator tuple as written + accountInfo.Status.CreatorTupleWritten = true + if err := workspaceClient.Status().Update(ctx, accountInfo); err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to update accountInfo status: %w", err), true, true) + } + } + + return ctrl.Result{}, nil +} + +func (w *workspaceFGASubroutine) writeTuple(ctx context.Context, storeID string, tuple *openfgav1.TupleKey) errors.OperatorError { + _, err := w.fga.Write(ctx, &openfgav1.WriteRequest{ + StoreId: storeID, + Writes: &openfgav1.WriteRequestWrites{ + TupleKeys: []*openfgav1.TupleKey{tuple}, + }, + }) + if helpers.IsDuplicateWriteError(err) { + return nil + } + if err != nil { + return errors.NewOperatorError(fmt.Errorf("unable to write FGA tuple: %w", err), true, true) + } + return nil +} + +var saRegex = regexp.MustCompile(`^system:serviceaccount:[^:]*:[^:]*$`) + +func formatUser(user string) string { + if saRegex.MatchString(user) { + return strings.ReplaceAll(user, ":", ".") + } + return user +} + +func validateCreator(creator string) bool { + if strings.HasPrefix(creator, "system:serviceaccount:") { + return false + } + if strings.HasPrefix(creator, "system.serviceaccount.") { + return false + } + return true +} diff --git a/internal/subroutine/workspace_fga_test.go b/internal/subroutine/workspace_fga_test.go new file mode 100644 index 0000000..2337c8c --- /dev/null +++ b/internal/subroutine/workspace_fga_test.go @@ -0,0 +1,555 @@ +package subroutine_test + +import ( + "context" + "testing" + + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + "github.com/kcp-dev/logicalcluster/v3" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + accountsv1alpha1 "github.com/platform-mesh/account-operator/api/v1alpha1" + "github.com/platform-mesh/security-operator/internal/subroutine" + "github.com/platform-mesh/security-operator/internal/subroutine/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" +) + +func TestWorkspaceFGA_Requeue_WhenAccountInfoMissing(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + + notFoundErr := &apierrors.StatusError{ErrStatus: metav1.Status{Reason: metav1.StatusReasonNotFound}} + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(notFoundErr) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, 1, int(res.RequeueAfter), "Expected immediate requeue") +} + +func TestWorkspaceFGA_Requeue_WhenAccountInfoIncomplete(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "" // missing + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, 1, int(res.RequeueAfter), "Expected immediate requeue") +} + +func TestWorkspaceFGA_WritesParentAndOwnerTuples(t *testing.T) { + mgr := mocks.NewMockManager(t) + + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + wsStatusWriter := mocks.NewMockStatusWriter(t) + creator := "user@example.com" + + // Mock Get for AccountInfo + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.ParentAccount = &accountsv1alpha1.AccountLocation{Name: "org", OriginClusterId: "root:orgs"} + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + + // Mock Status().Update() + wsClient.EXPECT().Status().Return(wsStatusWriter).Once() + wsStatusWriter.EXPECT().Update(mock.Anything, mock.MatchedBy(func(obj client.Object) bool { + ai := obj.(*accountsv1alpha1.AccountInfo) + assert.True(t, ai.Status.CreatorTupleWritten) + return true + }), mock.Anything).Return(nil).Once() + + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Times(3) + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{} + lc.Spec.Owner.Cluster = logicalcluster.Name("ws-owner").String() + lc.Spec.Owner.Name = "acc" + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, int64(0), res.RequeueAfter.Nanoseconds(), "Expected no requeue") +} + +func TestWorkspaceFGA_InvalidCreator_ReturnsError(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + creator := "system:serviceaccount:ns:name" + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{} + lc.Spec.Owner.Cluster = logicalcluster.Name("ws-owner").String() + lc.Spec.Owner.Name = "acc" + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_OnlyParentTuple_WhenNoCreator(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.ParentAccount = &accountsv1alpha1.AccountLocation{Name: "org", OriginClusterId: "root:orgs"} + ai.Spec.Creator = nil // no creator set + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{} + lc.Spec.Owner.Cluster = logicalcluster.Name("ws-owner").String() + lc.Spec.Owner.Name = "acc" + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, int64(0), res.RequeueAfter.Nanoseconds(), "Expected no requeue") +} + +func TestWorkspaceFGA_SkipsCreatorTuple_WhenAlreadyWritten(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + creator := "user@example.com" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.ParentAccount = &accountsv1alpha1.AccountLocation{Name: "org", OriginClusterId: "root:orgs"} + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = true // Already written + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // Only one write for parent tuple, no creator tuples + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{} + lc.Spec.Owner.Cluster = logicalcluster.Name("ws-owner").String() + lc.Spec.Owner.Name = "acc" + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, int64(0), res.RequeueAfter.Nanoseconds(), "Expected no requeue") +} + +func TestWorkspaceFGA_NoParentTuple_ForOrgAccount(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + creator := "user@example.com" + wsStatusWriter := mocks.NewMockStatusWriter(t) + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "org" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.ParentAccount = nil // Org accounts don't have parent + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsClient.EXPECT().Status().Return(wsStatusWriter).Once() + wsStatusWriter.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // Only two writes for creator tuples, no parent tuple + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Times(2) + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{} + lc.Spec.Owner.Cluster = logicalcluster.Name("ws-owner").String() + lc.Spec.Owner.Name = "org" + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, int64(0), res.RequeueAfter.Nanoseconds(), "Expected no requeue") +} + +func TestWorkspaceFGA_WriteTupleError_Propagates(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.ParentAccount = &accountsv1alpha1.AccountLocation{Name: "org", OriginClusterId: "root:orgs"} + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{} + lc.Spec.Owner.Cluster = logicalcluster.Name("ws-owner").String() + lc.Spec.Owner.Name = "acc" + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_ClusterFromContextError_ReturnsError(t *testing.T) { + mgr := mocks.NewMockManager(t) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(nil, assert.AnError) + + fga := mocks.NewMockOpenFGAServiceClient(t) + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_EmptyCreator_SkipsCreatorTuples(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + creator := "" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.ParentAccount = &accountsv1alpha1.AccountLocation{Name: "org", OriginClusterId: "root:orgs"} + ai.Spec.Creator = &creator // empty string + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // Only one write for parent tuple, no creator tuples because creator is empty + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, int64(0), res.RequeueAfter.Nanoseconds(), "Expected no requeue") +} + +func TestWorkspaceFGA_CreatorAssigneeTupleWriteError_ReturnsError(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + creator := "user@example.com" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // First writeTuple call fails (assignee tuple) + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_CreatorOwnerTupleWriteError_ReturnsError(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + creator := "user@example.com" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // First writeTuple call succeeds (assignee tuple), second fails (owner tuple) + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Once() + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_StatusUpdateError_ReturnsError(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + wsStatusWriter := mocks.NewMockStatusWriter(t) + creator := "user@example.com" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsClient.EXPECT().Status().Return(wsStatusWriter).Once() + wsStatusWriter.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(assert.AnError).Once() + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // Both writeTuple calls succeed + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Times(2) + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_ServiceAccountCreator_FormatsCorrectly(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + wsStatusWriter := mocks.NewMockStatusWriter(t) + // Use a regular user that doesn't match service account pattern for formatUser to return unchanged + creator := "user@example.com" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsClient.EXPECT().Status().Return(wsStatusWriter).Once() + wsStatusWriter.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + // Expect the user unchanged since it doesn't match service account pattern + fga.EXPECT().Write(mock.Anything, mock.MatchedBy(func(req *openfgav1.WriteRequest) bool { + if len(req.Writes.TupleKeys) == 0 { + return false + } + tuple := req.Writes.TupleKeys[0] + expectedUser := "user:user@example.com" + return tuple.User == expectedUser + })).Return(&openfgav1.WriteResponse{}, nil).Once() + fga.EXPECT().Write(mock.Anything, mock.Anything).Return(&openfgav1.WriteResponse{}, nil).Once() + + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + res, opErr := sub.Process(ctx, lc) + assert.Nil(t, opErr) + assert.Equal(t, int64(0), res.RequeueAfter.Nanoseconds(), "Expected no requeue") +} + +func TestWorkspaceFGA_ValidateCreator_WithDottedServiceAccount_ReturnsError(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + // Use a dotted service account format that should be rejected by validateCreator + creator := "system.serviceaccount.ns.name" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_ServiceAccountFormatted_ThenValidated(t *testing.T) { + mgr := mocks.NewMockManager(t) + wsCluster := mocks.NewMockCluster(t) + wsClient := mocks.NewMockClient(t) + // Use a system service account that should be formatted from : to . and then rejected + creator := "system:serviceaccount:ns:name" + + wsClient.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + ai := obj.(*accountsv1alpha1.AccountInfo) + ai.Spec.Account.Name = "acc" + ai.Spec.Account.OriginClusterId = "root:orgs" + ai.Spec.FGA.Store.Id = "store-1" + ai.Spec.Creator = &creator + ai.Status.CreatorTupleWritten = false + return nil + }, + ) + wsCluster.EXPECT().GetClient().Return(wsClient) + mgr.EXPECT().ClusterFromContext(mock.Anything).Return(wsCluster, nil) + + fga := mocks.NewMockOpenFGAServiceClient(t) + sub := subroutine.NewWorkspaceFGASubroutine(mgr, fga, "core_platform-mesh_io_account", "parent", "owner") + + lc := &kcpcorev1alpha1.LogicalCluster{} + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + // Should get an error because after formatting system:serviceaccount:ns:name becomes system.serviceaccount.ns.name + // which is rejected by validateCreator + assert.NotNil(t, opErr) +} + +func TestWorkspaceFGA_InterfaceMethods(t *testing.T) { + sub := subroutine.NewWorkspaceFGASubroutine(nil, nil, "core_platform-mesh_io_account", "parent", "owner") + + // Test GetName + assert.Equal(t, "WorkspaceFGA", sub.GetName()) + + // Test Finalizers + finalizers := sub.Finalizers(nil) + assert.Nil(t, finalizers) + + // Test Finalize + result, err := sub.Finalize(context.Background(), nil) + assert.Nil(t, err) + assert.Equal(t, int64(0), result.RequeueAfter.Nanoseconds()) +} diff --git a/internal/subroutine/workspace_initializer.go b/internal/subroutine/workspace_initializer.go index 978cf15..bae028b 100644 --- a/internal/subroutine/workspace_initializer.go +++ b/internal/subroutine/workspace_initializer.go @@ -5,17 +5,21 @@ import ( "fmt" "os" "strings" + "time" kcpv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + "github.com/kcp-dev/logicalcluster/v3" accountsv1alpha1 "github.com/platform-mesh/account-operator/api/v1alpha1" + "github.com/platform-mesh/account-operator/pkg/subroutines/accountinfo" "github.com/platform-mesh/golang-commons/controller/lifecycle/runtimeobject" lifecyclesubroutine "github.com/platform-mesh/golang-commons/controller/lifecycle/subroutine" - "github.com/platform-mesh/golang-commons/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" "github.com/platform-mesh/security-operator/api/v1alpha1" @@ -25,31 +29,28 @@ import ( func NewWorkspaceInitializer(orgsClient client.Client, cfg config.Config, mgr mcmanager.Manager) *workspaceInitializer { coreModulePath := cfg.CoreModulePath - // read file from path - res, err := os.ReadFile(coreModulePath) + data, err := os.ReadFile(coreModulePath) if err != nil { panic(err) } return &workspaceInitializer{ - orgsClient: orgsClient, - coreModule: string(res), - initializerName: cfg.InitializerName, - mgr: mgr, + orgsClient: orgsClient, + mgr: mgr, + coreModule: string(data), } } var _ lifecyclesubroutine.Subroutine = &workspaceInitializer{} type workspaceInitializer struct { - orgsClient client.Client - mgr mcmanager.Manager - coreModule string - initializerName string + orgsClient client.Client + mgr mcmanager.Manager + coreModule string } func (w *workspaceInitializer) Finalize(ctx context.Context, instance runtimeobject.RuntimeObject) (ctrl.Result, errors.OperatorError) { - // TODO: implement once finalizing workspaces are a thing + // Finalization handled by dedicated subroutine. return ctrl.Result{}, nil } @@ -62,55 +63,154 @@ func (w *workspaceInitializer) GetName() string { return "WorkspaceInitializer" func (w *workspaceInitializer) Process(ctx context.Context, instance runtimeobject.RuntimeObject) (ctrl.Result, errors.OperatorError) { lc := instance.(*kcpv1alpha1.LogicalCluster) - store := v1alpha1.Store{ - ObjectMeta: metav1.ObjectMeta{Name: generateStoreName(lc)}, - } - - _, err := controllerutil.CreateOrUpdate(ctx, w.orgsClient, &store, func() error { - store.Spec = v1alpha1.StoreSpec{ - Tuples: []v1alpha1.Tuple{ - { - Object: "role:authenticated", - Relation: "assignee", - User: "user:*", - }, - { - Object: fmt.Sprintf("core_platform-mesh_io_account:%s/%s", lc.Spec.Owner.Cluster, lc.Spec.Owner.Name), - Relation: "member", - User: "role:authenticated#assignee", - }, - }, - CoreModule: w.coreModule, + // Validate that owner cluster is specified before getting workspace client + if lc.Spec.Owner.Cluster == "" { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("spec.owner.cluster is empty for LogicalCluster %s", lc.Name), + true, true) + } + + clusterRef, 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) + } + workspaceClient := clusterRef.GetClient() + + // Use orgsClient directly since lc.Spec.Owner.Cluster contains short cluster ID + // which cannot be resolved via mgr.GetCluster() + var account accountsv1alpha1.Account + if err := w.orgsClient.Get(ctx, client.ObjectKey{Name: lc.Spec.Owner.Name}, &account); err != nil { + if kerrors.IsNotFound(err) { + return ctrl.Result{Requeue: true}, nil } + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get owner account: %w", err), true, true) + } + // Ensure AccountInfo exists (create if missing) so account-operator can populate it + accountInfo := &accountsv1alpha1.AccountInfo{ObjectMeta: metav1.ObjectMeta{Name: accountinfo.DefaultAccountInfoName}} + _, err = controllerutil.CreateOrUpdate(ctx, workspaceClient, accountInfo, func() error { + // Set Creator immediately when creating AccountInfo to avoid race with account-operator + if accountInfo.Spec.Creator == nil && account.Spec.Creator != nil { + creatorValue := *account.Spec.Creator + accountInfo.Spec.Creator = &creatorValue + } return nil }) if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to create/update store: %w", err), true, true) + // If APIBinding not ready yet, return error to retry whole reconcile + if strings.Contains(err.Error(), "no matches for kind") { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("apiBinding not ready: %w", err), true, false) + } + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to ensure accountInfo exists: %w", err), true, true) } - if store.Status.StoreID == "" { - // Store is not ready yet, requeue - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("store id is empty"), true, false) + // Only create Store for org accounts during initialization + // For account-type accounts, Store already exists in parent org + if account.Spec.Type != accountsv1alpha1.AccountTypeOrg { + // Re-fetch AccountInfo to get latest state populated by account-operator + accountInfo = &accountsv1alpha1.AccountInfo{ObjectMeta: metav1.ObjectMeta{Name: accountinfo.DefaultAccountInfoName}} + if err := workspaceClient.Get(ctx, client.ObjectKey{Name: accountinfo.DefaultAccountInfoName}, accountInfo); err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get accountInfo: %w", err), true, true) + } + + // Wait for account-operator to populate organization path + if accountInfo.Spec.Organization.Path == "" { + return ctrl.Result{RequeueAfter: time.Second}, nil + } + + // Resolve parent org's Store name from organization path + storeName := generateStoreNameFromPath(accountInfo.Spec.Organization.Path) + if storeName == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to derive store name from organization path"), true, false) + } + storeClusterName := logicalcluster.Name(accountInfo.Spec.Organization.Path) + + ctxStore := mccontext.WithCluster(ctx, storeClusterName.String()) + + // Get parent org's Store + store := &v1alpha1.Store{} + if err := w.orgsClient.Get(ctxStore, client.ObjectKey{Name: storeName}, store); err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get parent org store: %w", err), true, true) + } + + if store.Status.StoreID == "" { + return ctrl.Result{RequeueAfter: time.Second}, nil + } + + // Update AccountInfo with parent org's Store ID + accountInfo = &accountsv1alpha1.AccountInfo{ObjectMeta: metav1.ObjectMeta{Name: accountinfo.DefaultAccountInfoName}} + _, err = controllerutil.CreateOrUpdate(ctx, workspaceClient, accountInfo, func() error { + accountInfo.Spec.FGA.Store.Id = store.Status.StoreID + // Also set Creator if available + if account.Spec.Creator != nil { + creatorValue := *account.Spec.Creator + accountInfo.Spec.Creator = &creatorValue + } + return nil + }) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to update accountInfo with store ID: %w", err), true, true) + } + + return ctrl.Result{RequeueAfter: time.Minute}, nil + } + + // Resolve Store name and location for org accounts + path, ok := lc.Annotations["kcp.io/path"] + if !ok { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get workspace path"), true, false) + } + storeName := generateStoreName(lc) + if storeName == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to generate store name from workspace path"), true, false) } + storeClusterName := logicalcluster.Name(path) + + ctxStore := mccontext.WithCluster(ctx, storeClusterName.String()) - cluster, err := w.mgr.ClusterFromContext(ctx) + // Create Store for org account + store := &v1alpha1.Store{} + if err := w.orgsClient.Get(ctxStore, client.ObjectKey{Name: storeName}, store); err != nil { + if !kerrors.IsNotFound(err) { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get store: %w", err), true, true) + } + // Store doesn't exist, create it + store = &v1alpha1.Store{ObjectMeta: metav1.ObjectMeta{Name: storeName}} + } + + _, err = controllerutil.CreateOrUpdate(ctxStore, w.orgsClient, store, func() error { + store.Spec.CoreModule = w.coreModule + return nil + }) if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get cluster from context: %w", err), true, false) + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to create/update store: %w", err), true, true) + } + + // Re-fetch to get store status + if err := w.orgsClient.Get(ctxStore, client.ObjectKey{Name: storeName}, store); err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to refresh store status: %w", err), true, true) } - accountInfo := accountsv1alpha1.AccountInfo{ - ObjectMeta: metav1.ObjectMeta{Name: "account"}, + if store.Status.StoreID == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("store not ready"), true, false) } - _, err = controllerutil.CreateOrUpdate(ctx, cluster.GetClient(), &accountInfo, func() error { + + // Update AccountInfo with Store ID and Creator + accountInfo = &accountsv1alpha1.AccountInfo{ObjectMeta: metav1.ObjectMeta{Name: accountinfo.DefaultAccountInfoName}} + _, err = controllerutil.CreateOrUpdate(ctx, workspaceClient, accountInfo, func() error { accountInfo.Spec.FGA.Store.Id = store.Status.StoreID + // Copy creator value (not pointer) to avoid issues with pointer sharing + if account.Spec.Creator != nil { + creatorValue := *account.Spec.Creator + accountInfo.Spec.Creator = &creatorValue + } return nil }) if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to create/update accountInfo: %w", err), true, true) + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to update accountInfo: %w", err), true, true) } - return ctrl.Result{}, nil + return ctrl.Result{RequeueAfter: time.Minute}, nil } func generateStoreName(lc *kcpv1alpha1.LogicalCluster) string { @@ -120,3 +220,11 @@ func generateStoreName(lc *kcpv1alpha1.LogicalCluster) string { } return "" } + +func generateStoreNameFromPath(path string) string { + pathElements := strings.Split(path, ":") + if len(pathElements) == 0 { + return "" + } + return pathElements[len(pathElements)-1] +} diff --git a/internal/subroutine/workspace_initializer_test.go b/internal/subroutine/workspace_initializer_test.go new file mode 100644 index 0000000..11c6c83 --- /dev/null +++ b/internal/subroutine/workspace_initializer_test.go @@ -0,0 +1,48 @@ +package subroutine_test + +import ( + "context" + "os" + "testing" + + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + "github.com/platform-mesh/security-operator/internal/config" + "github.com/platform-mesh/security-operator/internal/subroutine" + "github.com/platform-mesh/security-operator/internal/subroutine/mocks" + "github.com/stretchr/testify/assert" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" +) + +func TestWorkspaceInitializer_ErrorWhenOwnerClusterEmpty(t *testing.T) { + // Create temp file for core module + tmpFile, err := os.CreateTemp("", "coreModule*.fga") + assert.NoError(t, err) + defer func() { _ = os.Remove(tmpFile.Name()) }() + _, err = tmpFile.WriteString("model\n schema 1.1") + assert.NoError(t, err) + err = tmpFile.Close() + assert.NoError(t, err) + + mgr := mocks.NewMockManager(t) + orgsClient := mocks.NewMockClient(t) + + cfg := config.Config{ + CoreModulePath: tmpFile.Name(), + } + cfg.FGA.ObjectType = "core_platform-mesh_io_account" + cfg.FGA.ParentRelation = "parent" + cfg.FGA.CreatorRelation = "owner" + + sub := subroutine.NewWorkspaceInitializer(orgsClient, cfg, mgr) + + lc := &kcpcorev1alpha1.LogicalCluster{} + lc.Name = "test-workspace" + lc.Spec.Owner = &kcpcorev1alpha1.LogicalClusterOwner{ + Cluster: "", // Empty cluster + } + + ctx := mccontext.WithCluster(context.Background(), "ws") + _, opErr := sub.Process(ctx, lc) + + assert.NotNil(t, opErr, "Expected error when owner.cluster is empty") +}