Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions cmd/initializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -118,14 +119,20 @@ var initializerCmd = &cobra.Command{
return err
}
defer func() { _ = conn.Close() }()
fga := openfgav1.NewOpenFGAServiceClient(conn)
fgaClient := openfgav1.NewOpenFGAServiceClient(conn)
storeIDGetter := fga.NewCachingStoreIDGetter(
fgaClient,
initializerCfg.FGA.StoreIDCacheTTL,
cmd.Context(),
log,
)

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)
Expand Down
11 changes: 9 additions & 2 deletions cmd/terminator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -104,9 +105,15 @@ var terminatorCmd = &cobra.Command{
os.Exit(1)
}
defer func() { _ = conn.Close() }()
fga := openfgav1.NewOpenFGAServiceClient(conn)
fgaClient := openfgav1.NewOpenFGAServiceClient(conn)
storeIDGetter := fga.NewCachingStoreIDGetter(
fgaClient,
terminatorCfg.FGA.StoreIDCacheTTL,
cmd.Context(),
log,
)

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)
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
module github.com/platform-mesh/security-operator

go 1.25.7
go 1.26

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.2
github.com/fluxcd/source-controller/api v1.8.1
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
Expand Down Expand Up @@ -39,7 +41,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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,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=
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"os"
"time"

"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -30,6 +31,7 @@ type FGAConfig struct {
ObjectType string
ParentRelation string
CreatorRelation string
StoreIDCacheTTL time.Duration
}

type KCPConfig struct {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions internal/controller/accountlogicalcluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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().
Expand Down
120 changes: 120 additions & 0 deletions internal/fga/storeid_getter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package fga

import (
"context"
"fmt"
"time"

"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"
)

// 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 {
cache *ttlcache.Cache[string, string]
loader *storeIDLoader
logger *logger.Logger
}

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: cache,
loader: loader,
logger: log,
}
}

// Get returns the store ID for the given store name.
func (m *CachingStoreIDGetter) Get(ctx context.Context, storeName string) (string, error) {
item := m.cache.Get(storeName)
if err := m.loader.Err(); err != nil {
return "", fmt.Errorf("populating cache: %w", err)
}

if item != nil {
return item.Value(), nil
}

return "", fmt.Errorf("store %q not found", storeName)
}

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 := l.fga.ListStores(l.loadCtx, &openfgav1.ListStoresRequest{
PageSize: wrapperspb.Int32(100),
ContinuationToken: continuationToken,
})
if err != nil {
l.loadErrer = fmt.Errorf("listing Stores in OpenFGA: %w", err)
return nil
}

for _, store := range resp.GetStores() {
if item := c.Set(store.GetName(), store.GetId(), ttlcache.DefaultTTL); item.Key() == storeName {
wantedItem = item
}
}

continuationToken = resp.GetContinuationToken()
if continuationToken == "" {
break
}
}

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)
87 changes: 87 additions & 0 deletions internal/fga/storeid_getter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package fga

import (
"context"
"errors"
"testing"
"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"
"github.com/stretchr/testify/require"
)

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()

log := testlogger.New()
getter := NewCachingStoreIDGetter(client, 5*time.Minute, context.Background(), log.Logger)

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()

loadCtx := context.Background()
log := testlogger.New()
getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx, log.Logger)

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()

loadCtx := context.Background()
log := testlogger.New()
getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx, log.Logger)

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()

loadCtx := context.Background()
log := testlogger.New()
getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx, log.Logger)

id, err := getter.Get(context.Background(), "foo")
assert.Error(t, err)
assert.Empty(t, id)
})
}
File renamed without changes.
Loading
Loading