diff --git a/docs/Configuring.md b/docs/Configuring.md index c04eb92e24..4b71e5569d 100644 --- a/docs/Configuring.md +++ b/docs/Configuring.md @@ -488,6 +488,14 @@ Root level key `authorization` | `request_limits.get_decision_multi_resource_resources_max` | Maximum resources allowed in `GetDecisionMultiResourceRequest` | `1000` | | | `request_limits.get_decision_bulk_decision_requests_max` | Maximum decision requests allowed in `GetDecisionBulkRequest` | `200` | | +Authorization v2 compiles registered-resource and obligation indexes once per cache +refresh and shares the completed snapshot across requests. The first successful +refresh is shared by concurrent callers. A failed refresh retains the last successful +snapshot, so policy changes become visible after a successful refresh. Caching remains +disabled by default. Attributes and subject mappings are refreshed only when direct +entitlements or dynamic value mappings are enabled; ordinary decisions fetch their +required attributes directly from the policy service. + #### Example: Authorization v1 ```yaml diff --git a/service/authorization/v2/authorization.go b/service/authorization/v2/authorization.go index 3cf6fe0992..e49e6a1216 100644 --- a/service/authorization/v2/authorization.go +++ b/service/authorization/v2/authorization.go @@ -17,7 +17,6 @@ import ( "github.com/opentdf/platform/service/internal/access/v2" "github.com/opentdf/platform/service/logger" ctxAuth "github.com/opentdf/platform/service/pkg/auth" - "github.com/opentdf/platform/service/pkg/cache" "github.com/opentdf/platform/service/pkg/serviceregistry" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/types/known/wrapperspb" @@ -85,12 +84,6 @@ func NewRegistration() *serviceregistry.Service[authzV2Connect.AuthorizationServ return as, nil } - cacheClient, err := srp.NewCacheClient(cache.Options{}) - if err != nil || cacheClient == nil { - l.Error("failed to create platform cache client", slog.Any("error", err)) - panic(fmt.Errorf("failed to create platform cache client: %w", err)) - } - refreshInterval, err := time.ParseDuration(authZCfg.Cache.RefreshInterval) if err != nil { l.Error("failed to parse entitlement policy cache refresh interval", slog.Any("error", err)) @@ -98,7 +91,9 @@ func NewRegistration() *serviceregistry.Service[authzV2Connect.AuthorizationServ } retriever := access.NewEntitlementPolicyRetriever(as.sdk) - as.cache, err = NewEntitlementPolicyCache(context.Background(), l, retriever, cacheClient, refreshInterval, authZCfg.AllowDynamicValueMappings) + as.cache, err = NewEntitlementPolicyCache(context.Background(), l, retriever, refreshInterval, access.PolicyOptions{ + AllowDirectEntitlements: authZCfg.AllowDirectEntitlements, AllowDynamicValueMappings: authZCfg.AllowDynamicValueMappings, NamespacedPolicy: authZCfg.EnforceNamespacedEntitlements, + }) if err != nil { l.Error("failed to create entitlement policy cache", slog.Any("error", err)) panic(fmt.Errorf("failed to create entitlement policy cache: %w", err)) diff --git a/service/authorization/v2/cache.go b/service/authorization/v2/cache.go index 8979a32dec..28f0aa2970 100644 --- a/service/authorization/v2/cache.go +++ b/service/authorization/v2/cache.go @@ -5,395 +5,210 @@ import ( "errors" "fmt" "log/slog" + "sync" + "sync/atomic" "time" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/service/internal/access/v2" "github.com/opentdf/platform/service/logger" - "github.com/opentdf/platform/service/pkg/cache" -) - -const ( - attributesCacheKey = "attributes_cache_key" - subjectMappingsCacheKey = "subject_mappings_cache_key" - dynamicValueMappingsCacheKey = "dynamic_value_mappings_cache_key" - registeredResourcesCacheKey = "registered_resources_cache_key" - obligationsCacheKey = "obligations_cache_key" ) var ( - // Cache tags for authorization-related data set in the cache - authzCacheTags = []string{"authorization", "policy", "entitlements"} - - // stopTimeout is the maximum time to wait for the periodic refresh goroutine to stop - stopTimeout = 5 * time.Second - - // valid minimum refresh interval for the cache (too frequently may overload policy services) - minRefreshInterval = 15 * time.Second - maxRefreshInterval = 1 * time.Hour - - ErrInvalidCacheConfig = errors.New("invalid cache configuration") - ErrFailedToStartCache = errors.New("failed to start EntitlementPolicyCache") - ErrFailedToRefreshCache = errors.New("failed to refresh EntitlementPolicyCache") - ErrFailedToSet = errors.New("failed to set cache with fresh entitlement policy") - ErrFailedToGet = errors.New("failed to get cached entitlement policy") - ErrCacheDisabled = errors.New("EntitlementPolicyCache is disabled (refresh interval is 0 seconds)") - ErrCachedTypeNotExpected = errors.New("cached data is not of expected type") + stopTimeout = 5 * time.Second + minRefreshInterval = 15 * time.Second + maxRefreshInterval = 1 * time.Hour + ErrInvalidCacheConfig = errors.New("invalid cache configuration") + ErrCacheDisabled = errors.New("EntitlementPolicyCache is disabled (refresh interval is 0 seconds)") + ErrFailedToRefreshCache = errors.New("failed to refresh EntitlementPolicyCache") ) -// EntitlementPolicyCache caches attributes, subject mappings, registered resources, obligations, and -// (when enabled) dynamic value mappings with periodic refresh. The default decision path fetches -// attributes and subject mappings per request via GetEntitleableAttributesByFqns; the cached copies -// back the full-policy fallback used when direct entitlements or dynamic value mappings are enabled. +// EntitlementPolicyCache publishes one complete immutable snapshot per refresh. +// Readers keep their snapshot while a replacement is fetched and compiled. type EntitlementPolicyCache struct { - logger *logger.Logger - cacheClient *cache.Cache - - // SDK-connected retriever to fetch fresh data from policy services - retriever *access.EntitlementPolicyRetriever - - // allowDynamicValueMappings gates fetching the experimental dynamic value mappings, so cache - // health does not depend on that endpoint when the feature is disabled. - allowDynamicValueMappings bool - - // Refresh state + logger *logger.Logger + retriever access.EntitlementPolicyStore + options access.PolicyOptions + snapshot atomic.Pointer[EntitlementPolicy] + refreshMu sync.Mutex configuredRefreshInterval time.Duration stopRefresh chan struct{} refreshCompleted chan struct{} - - // isCacheFilled indicates if the cache has been filled - isCacheFilled bool + stopOnce sync.Once } -// The EntitlementPolicy struct holds all the cached entitlement policy, as generics allow one -// data type per service cache instance. +// EntitlementPolicy holds the raw policy and its compiled evaluation indexes. type EntitlementPolicy struct { Attributes []*policy.Attribute SubjectMappings []*policy.SubjectMapping DynamicValueMappings []*policy.DynamicValueMapping RegisteredResources []*policy.RegisteredResource Obligations []*policy.Obligation + prepared *access.PreparedPolicy } -// NewEntitlementPolicyCache holds a platform-provided cache client and manages a periodic refresh of -// cached entitlement policy data, fetching fresh data from the policy services at configured interval. -func NewEntitlementPolicyCache( - ctx context.Context, - l *logger.Logger, - retriever *access.EntitlementPolicyRetriever, - cacheClient *cache.Cache, - cacheRefreshInterval time.Duration, - allowDynamicValueMappings bool, -) (*EntitlementPolicyCache, error) { - if cacheRefreshInterval == 0 { +func NewEntitlementPolicyCache(ctx context.Context, l *logger.Logger, retriever access.EntitlementPolicyStore, interval time.Duration, options access.PolicyOptions) (*EntitlementPolicyCache, error) { + if interval <= 0 { return nil, ErrCacheDisabled } - l = l.With("component", "EntitlementPolicyCache") - - l.DebugContext(ctx, "initializing cache") - - instance := &EntitlementPolicyCache{ - logger: l, - cacheClient: cacheClient, - retriever: retriever, - allowDynamicValueMappings: allowDynamicValueMappings, - configuredRefreshInterval: cacheRefreshInterval, - stopRefresh: make(chan struct{}), - refreshCompleted: make(chan struct{}), + c := &EntitlementPolicyCache{ + logger: l.With("component", "EntitlementPolicyCache"), retriever: retriever, options: options, + configuredRefreshInterval: interval, stopRefresh: make(chan struct{}), refreshCompleted: make(chan struct{}), } - - // Try to start the cache - if err := instance.Start(ctx); err != nil { - return nil, errors.Join(ErrFailedToStartCache, err) - } - - // Only set the instance if Start() succeeds - l.DebugContext(ctx, "initialized EntitlementPolicyCache and started periodic refresh") - return instance, nil + go c.periodicRefresh(ctx) + return c, nil } -func (c *EntitlementPolicyCache) IsEnabled() bool { - return c != nil -} +func (c *EntitlementPolicyCache) IsEnabled() bool { return c != nil } func (c *EntitlementPolicyCache) IsReady(ctx context.Context) bool { - if !c.IsEnabled() || c.retriever == nil { + if c == nil || c.retriever == nil { return false } - if !c.isCacheFilled { - if err := c.Refresh(ctx); err != nil { - c.logger.ErrorContext(ctx, "cache is not ready", slog.Any("error", err)) - return false - } + if c.snapshot.Load() != nil { + return true + } + c.refreshMu.Lock() + defer c.refreshMu.Unlock() + // A concurrent request may have completed the first refresh while we waited. + if c.snapshot.Load() != nil { + return true + } + if err := c.refresh(ctx); err != nil { + c.logger.ErrorContext(ctx, "cache is not ready", slog.Any("error", err)) + return false } return true } -// Start initiates the cache and begins periodic refresh -func (c *EntitlementPolicyCache) Start(ctx context.Context) error { - // Reset channels in case Start is called multiple times - // Only reset if stopRefresh is closed or nil - select { - case <-c.stopRefresh: - // Channel was closed, recreate it - c.stopRefresh = make(chan struct{}) - c.refreshCompleted = make(chan struct{}) - default: - // Channel is still open, do nothing +func (c *EntitlementPolicyCache) PreparedPolicy(_ context.Context) (*access.PreparedPolicy, error) { + if snapshot := c.snapshot.Load(); snapshot != nil { + return snapshot.prepared, nil } + return nil, ErrFailedToRefreshCache +} - c.logger.DebugContext(ctx, - "starting periodic cache refresh", - slog.Float64("seconds", c.configuredRefreshInterval.Seconds()), - ) - go c.periodicRefresh(ctx) - - return nil +// Refresh retains the last successful snapshot if retrieval or compilation fails. +// Policy visibility still follows the configured refresh interval; caching remains opt-in. +func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { + c.refreshMu.Lock() + defer c.refreshMu.Unlock() + return c.refresh(ctx) } -// Stop stops the periodic refresh goroutine if it's running func (c *EntitlementPolicyCache) Stop() { - // Only attempt to stop the refresh goroutine if an interval was set - if c.configuredRefreshInterval > 0 { - // Check if stopRefresh is already closed - select { - case <-c.stopRefresh: - // Channel is already closed, nothing to do - c.logger.Debug("stop called on already stopped cache") - return - default: - // Channel is still open, proceed with closing - // Signal the goroutine to stop - close(c.stopRefresh) - // Wait with a timeout for the refresh goroutine to complete - select { - case <-c.refreshCompleted: - // Goroutine completed successfully - case <-time.After(stopTimeout): - // Timeout as a safety mechanism in case the goroutine is stuck - c.logger.Warn("timed out waiting for refresh goroutine to complete") - } - } + c.stopOnce.Do(func() { close(c.stopRefresh) }) + select { + case <-c.refreshCompleted: + case <-time.After(stopTimeout): + c.logger.Warn("timed out waiting for refresh goroutine to complete") } } -// Refresh manually refreshes the cache by reaching out to policy services. In the event of an error, -// the cache is marked as not filled, and the error is returned. -func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { - // Retrieve fresh data from the policy services. Attributes and subject mappings are cached so the - // full-policy fallback (direct entitlements / dynamic value mappings) can read them from the cache - // rather than re-scanning both endpoints on every request. - attributes, err := c.retriever.ListAllAttributes(ctx) - if err != nil { - return err - } - subjectMappings, err := c.retriever.ListAllSubjectMappings(ctx) - if err != nil { - return err - } - // Only fetch the experimental dynamic value mappings when enabled, so cache readiness does not - // depend on that endpoint while the feature is off. - var dynamicValueMappings []*policy.DynamicValueMapping - if c.allowDynamicValueMappings { - dynamicValueMappings, err = c.retriever.ListAllDynamicValueMappings(ctx) - if err != nil { - return err - } - } - registeredResources, err := c.retriever.ListAllRegisteredResources(ctx) - if err != nil { - return err - } - obligations, err := c.retriever.ListAllObligations(ctx) - if err != nil { - return err - } - - // If there is an error when Setting with fresh data, mark not filled so IsReady() will re-attempt refresh - err = c.cacheClient.Set(ctx, attributesCacheKey, attributes, authzCacheTags) - if err != nil { - c.isCacheFilled = false - return errors.Join(ErrFailedToSet, err) - } - - err = c.cacheClient.Set(ctx, subjectMappingsCacheKey, subjectMappings, authzCacheTags) - if err != nil { - c.isCacheFilled = false - return errors.Join(ErrFailedToSet, err) - } - - // Only cache dynamic value mappings when the feature is enabled, so a disabled feature does not - // store an empty slice (the fetch above is gated the same way). - if c.allowDynamicValueMappings { - err = c.cacheClient.Set(ctx, dynamicValueMappingsCacheKey, dynamicValueMappings, authzCacheTags) - if err != nil { - c.isCacheFilled = false - return errors.Join(ErrFailedToSet, err) - } - } - - err = c.cacheClient.Set(ctx, registeredResourcesCacheKey, registeredResources, authzCacheTags) - if err != nil { - c.isCacheFilled = false - return errors.Join(ErrFailedToSet, err) - } +func (p *EntitlementPolicy) IsEnabled() bool { return p != nil } +func (p *EntitlementPolicy) IsReady(context.Context) bool { return p != nil } +func (p *EntitlementPolicy) ListAllAttributes(context.Context) ([]*policy.Attribute, error) { + return p.Attributes, nil +} - err = c.cacheClient.Set(ctx, obligationsCacheKey, obligations, authzCacheTags) - if err != nil { - c.isCacheFilled = false - return errors.Join(ErrFailedToSet, err) - } +func (p *EntitlementPolicy) ListAllSubjectMappings(context.Context) ([]*policy.SubjectMapping, error) { + return p.SubjectMappings, nil +} - c.logger.DebugContext(ctx, - "refreshed EntitlementPolicyCache", - slog.Int("attributes_count", len(attributes)), - slog.Int("subject_mappings_count", len(subjectMappings)), - slog.Int("registered_resources_count", len(registeredResources)), - slog.Int("obligations_count", len(obligations)), - ) +func (p *EntitlementPolicy) ListAllDynamicValueMappings(context.Context) ([]*policy.DynamicValueMapping, error) { + return p.DynamicValueMappings, nil +} - // Mark the cache as filled after a successful refresh - c.isCacheFilled = true +func (p *EntitlementPolicy) ListAllRegisteredResources(context.Context) ([]*policy.RegisteredResource, error) { + return p.RegisteredResources, nil +} - return nil +func (p *EntitlementPolicy) ListAllObligations(context.Context) ([]*policy.Obligation, error) { + return p.Obligations, nil } -// ListAllAttributes returns the cached attributes func (c *EntitlementPolicyCache) ListAllAttributes(ctx context.Context) ([]*policy.Attribute, error) { - var ( - attributes []*policy.Attribute - ok bool - ) - - cached, err := c.cacheClient.Get(ctx, attributesCacheKey) - if err != nil { - if errors.Is(err, cache.ErrCacheMiss) { - return attributes, nil - } - return nil, fmt.Errorf("%w, attributes: %w", ErrFailedToGet, err) - } - - attributes, ok = cached.([]*policy.Attribute) - if !ok { - return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, attributes) - } - return attributes, nil + return c.current().ListAllAttributes(ctx) } -// ListAllSubjectMappings returns the cached subject mappings func (c *EntitlementPolicyCache) ListAllSubjectMappings(ctx context.Context) ([]*policy.SubjectMapping, error) { - var ( - subjectMappings []*policy.SubjectMapping - ok bool - ) - - cached, err := c.cacheClient.Get(ctx, subjectMappingsCacheKey) - if err != nil { - if errors.Is(err, cache.ErrCacheMiss) { - return subjectMappings, nil - } - return nil, fmt.Errorf("%w, subject mappings: %w", ErrFailedToGet, err) - } - - subjectMappings, ok = cached.([]*policy.SubjectMapping) - if !ok { - return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, subjectMappings) - } - return subjectMappings, nil + return c.current().ListAllSubjectMappings(ctx) } -// ListAllDynamicValueMappings returns the cached dynamic value entitlement mappings, or none on a cache miss func (c *EntitlementPolicyCache) ListAllDynamicValueMappings(ctx context.Context) ([]*policy.DynamicValueMapping, error) { - var ( - mappings []*policy.DynamicValueMapping - ok bool - ) - - cached, err := c.cacheClient.Get(ctx, dynamicValueMappingsCacheKey) - if err != nil { - if errors.Is(err, cache.ErrCacheMiss) { - return mappings, nil - } - return nil, fmt.Errorf("%w, dynamic value mappings: %w", ErrFailedToGet, err) - } - - mappings, ok = cached.([]*policy.DynamicValueMapping) - if !ok { - return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, cached) - } - return mappings, nil + return c.current().ListAllDynamicValueMappings(ctx) } -// ListAllRegisteredResources returns the cached registered resources, or none in the event of a cache miss func (c *EntitlementPolicyCache) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) { - var ( - registeredResources []*policy.RegisteredResource - ok bool - ) - - cached, err := c.cacheClient.Get(ctx, registeredResourcesCacheKey) - if err != nil { - if errors.Is(err, cache.ErrCacheMiss) { - return registeredResources, nil - } - return nil, fmt.Errorf("%w, registered resources: %w", ErrFailedToGet, err) - } - - registeredResources, ok = cached.([]*policy.RegisteredResource) - if !ok { - return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, registeredResources) - } - return registeredResources, nil + return c.current().ListAllRegisteredResources(ctx) } -// ListAllObligations returns the cached obligations, or none in the event of a cache miss func (c *EntitlementPolicyCache) ListAllObligations(ctx context.Context) ([]*policy.Obligation, error) { - var ( - obligations []*policy.Obligation - ok bool - ) + return c.current().ListAllObligations(ctx) +} - cached, err := c.cacheClient.Get(ctx, obligationsCacheKey) - if err != nil { - if errors.Is(err, cache.ErrCacheMiss) { - return obligations, nil +func (c *EntitlementPolicyCache) refresh(ctx context.Context) error { + if c.retriever == nil { + return ErrFailedToRefreshCache + } + next := &EntitlementPolicy{} + var err error + if c.options.AllowDirectEntitlements || c.options.AllowDynamicValueMappings { + next.Attributes, err = c.retriever.ListAllAttributes(ctx) + if err != nil { + return err + } + next.SubjectMappings, err = c.retriever.ListAllSubjectMappings(ctx) + if err != nil { + return err } - return nil, fmt.Errorf("%w, obligations: %w", ErrFailedToGet, err) } - - obligations, ok = cached.([]*policy.Obligation) - if !ok { - return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, obligations) + if c.options.AllowDynamicValueMappings { + next.DynamicValueMappings, err = c.retriever.ListAllDynamicValueMappings(ctx) + if err != nil { + return err + } } - return obligations, nil + next.RegisteredResources, err = c.retriever.ListAllRegisteredResources(ctx) + if err != nil { + return err + } + next.Obligations, err = c.retriever.ListAllObligations(ctx) + if err != nil { + return err + } + next.prepared, err = access.NewPreparedPolicy(ctx, c.logger, next, c.options) + if err != nil { + return fmt.Errorf("compile entitlement policy: %w", err) + } + c.snapshot.Store(next) + return nil } -// periodicRefresh refreshes the cache at the specified interval func (c *EntitlementPolicyCache) periodicRefresh(ctx context.Context) { - waitTimeout := c.configuredRefreshInterval - ticker := time.NewTicker(c.configuredRefreshInterval) - defer func() { - ticker.Stop() - // Always signal completion, regardless of how we exit - close(c.refreshCompleted) - }() - + defer ticker.Stop() + defer close(c.refreshCompleted) for { select { case <-ticker.C: - // Create a child context that can be canceled if refresh takes too long - refreshCtx, cancel := context.WithTimeout(ctx, waitTimeout) + refreshCtx, cancel := context.WithTimeout(ctx, c.configuredRefreshInterval) err := c.Refresh(refreshCtx) - cancel() // Always cancel the context to prevent leaks + cancel() if err != nil { c.logger.ErrorContext(ctx, "failed to refresh cache", slog.Any("error", err)) } case <-c.stopRefresh: return case <-ctx.Done(): - c.logger.DebugContext(ctx, "context canceled, stopping periodic refresh") return } } } + +func (c *EntitlementPolicyCache) current() *EntitlementPolicy { + if p := c.snapshot.Load(); p != nil { + return p + } + return &EntitlementPolicy{} +} diff --git a/service/authorization/v2/cache_test.go b/service/authorization/v2/cache_test.go index e2ae06fa05..682a4db7e0 100644 --- a/service/authorization/v2/cache_test.go +++ b/service/authorization/v2/cache_test.go @@ -1,142 +1,147 @@ package authorization import ( + "context" + "errors" + "sync" + "sync/atomic" "testing" "time" "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/service/internal/access/v2" "github.com/opentdf/platform/service/logger" - "github.com/opentdf/platform/service/pkg/cache" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var ( - mockCacheExpiry = 5 * time.Minute - l = logger.CreateTestLogger() -) - -func Test_NewEntitlementPolicyCache(t *testing.T) { - ctx := t.Context() - refreshInterval := 10 * time.Second - mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) - require.NoError(t, err) - assert.NotNil(t, c) - assert.Equal(t, refreshInterval, c.configuredRefreshInterval) - assert.False(t, c.isCacheFilled) +type snapshotRetriever struct { + EntitlementPolicy + attributes atomic.Int32 + mappings atomic.Int32 + resources atomic.Int32 + dynamic atomic.Int32 + fail atomic.Bool } -func Test_EntitlementPolicyCache_RefreshInterval(t *testing.T) { - var refreshInterval time.Duration - ctx := t.Context() - mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - - _, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) - require.ErrorIs(t, err, ErrCacheDisabled) - - refreshInterval = 10 * time.Second - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) - require.NoError(t, err) - assert.NotNil(t, c) +func (s *snapshotRetriever) ListAllAttributes(ctx context.Context) ([]*policy.Attribute, error) { + s.attributes.Add(1) + return s.EntitlementPolicy.ListAllAttributes(ctx) } -func Test_EntitlementPolicyCache_Enabled(t *testing.T) { - var ( - c *EntitlementPolicyCache - err error - ctx = t.Context() - refreshInterval = 10 * time.Second - mockCache, _ = cache.TestCacheClient(mockCacheExpiry) - ) - assert.False(t, c.IsEnabled()) - assert.False(t, c.IsReady(ctx)) - - c, err = NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) - require.NoError(t, err) - assert.NotNil(t, c) - assert.True(t, c.IsEnabled()) - // Retriever is nil, so cache is not ready - assert.False(t, c.IsReady(ctx)) +func (s *snapshotRetriever) ListAllSubjectMappings(ctx context.Context) ([]*policy.SubjectMapping, error) { + s.mappings.Add(1) + return s.EntitlementPolicy.ListAllSubjectMappings(ctx) } -func Test_EntitlementPolicyCache_CacheMiss(t *testing.T) { - ctx := t.Context() - mockCache, _ := cache.TestCacheClient(mockCacheExpiry) +func (s *snapshotRetriever) ListAllDynamicValueMappings(ctx context.Context) ([]*policy.DynamicValueMapping, error) { + s.dynamic.Add(1) + return s.EntitlementPolicy.ListAllDynamicValueMappings(ctx) +} - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour, false) - require.NoError(t, err) +func (s *snapshotRetriever) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) { + s.resources.Add(1) + if s.fail.Load() { + return nil, errors.New("policy unavailable") + } + return s.EntitlementPolicy.ListAllRegisteredResources(ctx) +} - // No errors, but empty lists on cache misses - attrs, err := c.ListAllAttributes(ctx) +func testSnapshotCache(t *testing.T, retriever access.EntitlementPolicyStore, options access.PolicyOptions) *EntitlementPolicyCache { + t.Helper() + c, err := NewEntitlementPolicyCache(t.Context(), logger.CreateTestLogger(), retriever, time.Hour, options) require.NoError(t, err) - assert.Empty(t, attrs) + t.Cleanup(c.Stop) + return c +} - subjectMappings, err := c.ListAllSubjectMappings(ctx) +func TestPolicyCacheColdRequestsShareOneSnapshot(t *testing.T) { + source := &snapshotRetriever{} + c := testSnapshotCache(t, source, access.PolicyOptions{}) + const concurrency = 32 + ready := make(chan bool, concurrency) + var workers sync.WaitGroup + for range concurrency { + workers.Go(func() { ready <- c.IsReady(t.Context()) }) + } + workers.Wait() + close(ready) + for ok := range ready { + require.True(t, ok) + } + require.EqualValues(t, 1, source.resources.Load()) + require.Zero(t, source.attributes.Load()) + require.Zero(t, source.mappings.Load()) + require.Zero(t, source.dynamic.Load()) + first, err := c.PreparedPolicy(t.Context()) require.NoError(t, err) - assert.Empty(t, subjectMappings) - - registeredResources, err := c.ListAllRegisteredResources(ctx) + second, err := c.PreparedPolicy(t.Context()) require.NoError(t, err) - assert.Empty(t, registeredResources) + require.Same(t, first, second) } -func Test_EntitlementPolicyCache_CacheHits(t *testing.T) { - ctx := t.Context() - mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - - attrsList := []*policy.Attribute{{Name: "attr1"}} - subjMappingsList := []*policy.SubjectMapping{{Id: "id-123"}} - resourcesList := []*policy.RegisteredResource{{Name: "res1"}} - _ = mockCache.Set(ctx, attributesCacheKey, attrsList, nil) - _ = mockCache.Set(ctx, subjectMappingsCacheKey, subjMappingsList, nil) - _ = mockCache.Set(ctx, registeredResourcesCacheKey, resourcesList, nil) - - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour, false) +func TestPolicyCacheFailedRefreshRetainsCompleteSnapshot(t *testing.T) { + source := &snapshotRetriever{EntitlementPolicy: EntitlementPolicy{ + RegisteredResources: []*policy.RegisteredResource{{Name: "first", Values: []*policy.RegisteredResourceValue{{Value: "value"}}}}, + }} + c := testSnapshotCache(t, source, access.PolicyOptions{}) + require.True(t, c.IsReady(t.Context())) + first, err := c.PreparedPolicy(t.Context()) require.NoError(t, err) - - // Allow for some concurrency overhead in cache library to prevent flakiness in tests - time.Sleep(10 * time.Millisecond) - - attrs, err := c.ListAllAttributes(ctx) + source.RegisteredResources = []*policy.RegisteredResource{{Name: "second", Values: []*policy.RegisteredResourceValue{{Value: "value"}}}} + source.fail.Store(true) + require.Error(t, c.Refresh(t.Context())) + retained, err := c.PreparedPolicy(t.Context()) require.NoError(t, err) - assert.Len(t, attrs, 1) - assert.Equal(t, "attr1", attrs[0].GetName()) - - subjectMappings, err := c.ListAllSubjectMappings(ctx) + require.Same(t, first, retained) + resources, err := c.ListAllRegisteredResources(t.Context()) require.NoError(t, err) - assert.Len(t, subjectMappings, 1) - assert.Equal(t, "id-123", subjectMappings[0].GetId()) - - registeredResources, err := c.ListAllRegisteredResources(ctx) + require.Equal(t, "first", resources[0].GetName()) + source.fail.Store(false) + require.NoError(t, c.Refresh(t.Context())) + replacement, err := c.PreparedPolicy(t.Context()) require.NoError(t, err) - assert.Len(t, registeredResources, 1) - assert.Equal(t, "res1", registeredResources[0].GetName()) -} - -func Test_EntitlementPolicyCache_DynamicValueMappings(t *testing.T) { - ctx := t.Context() - mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour, true) + require.NotSame(t, first, replacement) + resources, err = c.ListAllRegisteredResources(t.Context()) require.NoError(t, err) - assert.True(t, c.allowDynamicValueMappings) - - // Cache miss: empty result, no error - mappings, err := c.ListAllDynamicValueMappings(ctx) - require.NoError(t, err) - assert.Empty(t, mappings) + require.Equal(t, "second", resources[0].GetName()) +} - // Cache hit: returns what was set - dvmList := []*policy.DynamicValueMapping{{Id: "dvm-1"}} - _ = mockCache.Set(ctx, dynamicValueMappingsCacheKey, dvmList, nil) +func TestPolicyCacheFeatureSpecificLoads(t *testing.T) { + for _, options := range []access.PolicyOptions{ + {AllowDirectEntitlements: true}, {AllowDynamicValueMappings: true}, + } { + t.Run(fmtPolicyOptions(options), func(t *testing.T) { + source := &snapshotRetriever{EntitlementPolicy: EntitlementPolicy{Attributes: []*policy.Attribute{}, SubjectMappings: []*policy.SubjectMapping{}}} + c := testSnapshotCache(t, source, options) + require.True(t, c.IsReady(t.Context())) + require.EqualValues(t, 1, source.attributes.Load()) + require.EqualValues(t, 1, source.mappings.Load()) + if options.AllowDynamicValueMappings { + require.EqualValues(t, 1, source.dynamic.Load()) + } else { + require.Zero(t, source.dynamic.Load()) + } + }) + } +} - // Allow for some concurrency overhead in cache library to prevent flakiness in tests - time.Sleep(10 * time.Millisecond) +func fmtPolicyOptions(options access.PolicyOptions) string { + if options.AllowDirectEntitlements { + return "direct" + } + return "dynamic" +} - mappings, err = c.ListAllDynamicValueMappings(ctx) - require.NoError(t, err) - assert.Len(t, mappings, 1) - assert.Equal(t, "dvm-1", mappings[0].GetId()) +func TestPolicyCacheDisabledAndUnavailable(t *testing.T) { + var absent *EntitlementPolicyCache + require.False(t, absent.IsEnabled()) + require.False(t, absent.IsReady(t.Context())) + c := testSnapshotCache(t, nil, access.PolicyOptions{}) + require.True(t, c.IsEnabled()) + require.False(t, c.IsReady(t.Context())) + require.Error(t, c.Refresh(t.Context())) + _, err := c.PreparedPolicy(t.Context()) + require.ErrorIs(t, err, ErrFailedToRefreshCache) + _, err = NewEntitlementPolicyCache(t.Context(), nil, nil, 0, access.PolicyOptions{}) + require.ErrorIs(t, err, ErrCacheDisabled) } diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index afbba8e27c..61e97b6be6 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -13,7 +13,6 @@ import ( "github.com/opentdf/platform/protocol/go/entity" entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" "github.com/opentdf/platform/protocol/go/policy" - attrs "github.com/opentdf/platform/protocol/go/policy/attributes" "github.com/opentdf/platform/protocol/go/policy/subjectmapping" otdfSDK "github.com/opentdf/platform/sdk" ent "github.com/opentdf/platform/service/entity" @@ -92,83 +91,30 @@ func NewJustInTimePDP( } // If no store is provided, have EntitlementPolicyRetriever fetch from policy services - if !store.IsEnabled() || !store.IsReady(ctx) { + if store == nil || !store.IsEnabled() || !store.IsReady(ctx) { log.DebugContext(ctx, "no EntitlementPolicyStore provided or not yet ready, will retrieve directly from policy services") store = NewEntitlementPolicyRetriever(sdk) } - // Attributes and subject mappings are fetched per request (targeted), so they are no longer - // loaded here. Registered resources, obligations, and (gated) dynamic value mappings remain - // fully loaded because they are not covered by GetEntitleableAttributesByFqns. - allRegisteredResources, err := store.ListAllRegisteredResources(ctx) - if err != nil { - return nil, fmt.Errorf("failed to fetch all registered resources: %w", err) - } - allObligations, err := store.ListAllObligations(ctx) - if err != nil { - return nil, fmt.Errorf("failed to fetch all obligations: %w", err) - } - // Experimental: only load dynamic value mappings when the feature is enabled. - if allowDynamicValueMappings { - p.dynamicValueMappings, err = store.ListAllDynamicValueMappings(ctx) + options := PolicyOptions{allowDirectEntitlements, allowDynamicValueMappings, namespacedPolicy} + var prepared *PreparedPolicy + if provider, ok := store.(PreparedPolicyStore); ok { + prepared, err = provider.PreparedPolicy(ctx) if err != nil { - return nil, fmt.Errorf("failed to fetch all dynamic value mappings: %w", err) + return nil, err } } - p.registeredResources = allRegisteredResources - - registeredResourceValuesByFQN, err := buildRegisteredResourceValuesByFQN(allRegisteredResources, namespacedPolicy) - if err != nil { - return nil, fmt.Errorf("failed to index registered resources: %w", err) - } - p.registeredResourceValuesByFQN = registeredResourceValuesByFQN - - // Obligations are triggered by (action, attribute value FQN, PEP client) against a trigger graph - // built from all obligations; the attributes-by-value map is unused by the obligations PDP, so an - // empty map is passed. - obligationsPDP, err := obligations.NewObligationsPolicyDecisionPoint( - ctx, - log, - make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), - registeredResourceValuesByFQN, - allObligations, - ) - if err != nil { - return nil, fmt.Errorf("failed to create new obligations policy decision point: %w", err) - } - p.obligationsPDP = obligationsPDP - - // Direct entitlements and dynamic value mappings entitle attribute values that may not exist in - // policy; synthesizing them requires the full definition set, which targeted - // GetEntitleableAttributesByFqns lookups cannot supply (a non-existent value FQN errors). When - // either experimental feature is enabled, build the PDP from the full policy load instead. - if allowDirectEntitlements || allowDynamicValueMappings { - // Read attributes and subject mappings from the same store used above (the refresh cache when - // ready, otherwise the live retriever), so a cache-enabled deployment does not re-scan both - // policy endpoints on every request. - allAttributes, err := store.ListAllAttributes(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list attributes: %w", err) - } - allSubjectMappings, err := store.ListAllSubjectMappings(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list subject mappings: %w", err) - } - fullPolicyPDP, err := NewPolicyDecisionPoint( - ctx, - log, - allAttributes, - allSubjectMappings, - allRegisteredResources, - allowDirectEntitlements, - namespacedPolicy, - WithDynamicValueMappings(p.dynamicValueMappings, allowDynamicValueMappings), - ) + if prepared == nil || prepared.options != options { + prepared, err = NewPreparedPolicy(ctx, log, store, options) if err != nil { - return nil, fmt.Errorf("failed to create full-policy decision point: %w", err) + return nil, err } - p.fullPolicyPDP = fullPolicyPDP } + p.registeredResources = prepared.registeredResources + p.registeredResourceValuesByFQN = prepared.registeredResourceValuesByFQN + p.dynamicValueMappings = prepared.dynamicValueMappings + p.obligationsPDP = prepared.obligationsPDP + p.fullPolicyPDP = prepared.fullPolicyPDP return p, nil } @@ -421,6 +367,7 @@ func (p *JustInTimePDP) buildInnerPDP(ctx context.Context, valueFQNs []string) ( p.allowDirectEntitlements, p.namespacedPolicy, WithDynamicValueMappings(p.dynamicValueMappings, p.allowDynamicValueMappings), + withRegisteredResourceValues(p.registeredResourceValuesByFQN), ) if err != nil { return nil, fmt.Errorf("failed to create request-scoped policy decision point: %w", err) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 139ec84a20..dc1c4de07d 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -79,6 +79,8 @@ var ( // pdpOptions holds optional, experimental PolicyDecisionPoint features. type pdpOptions struct { + registeredResourceValues map[string]*policy.RegisteredResourceValue + dynamicValueMappings []*policy.DynamicValueMapping allowDynamicValueMappings bool } @@ -95,6 +97,10 @@ func WithDynamicValueMappings(mappings []*policy.DynamicValueMapping, allow bool } } +func withRegisteredResourceValues(values map[string]*policy.RegisteredResourceValue) PDPOption { + return func(options *pdpOptions) { options.registeredResourceValues = values } +} + // NewPolicyDecisionPoint creates a new Policy Decision Point instance. // It is presumed that all Attribute Definitions and Subject Mappings are valid and contain the entirety of entitlement policy. // Attribute Values without Subject Mappings will be ignored in decisioning. The experimental dynamic @@ -220,9 +226,12 @@ func NewPolicyDecisionPoint( dynamicMappingsByDefinitionFQN[definitionFQN] = append(dynamicMappingsByDefinitionFQN[definitionFQN], mapping) } - allRegisteredResourceValuesByFQN, err := buildRegisteredResourceValuesByFQN(allRegisteredResources, namespacedPolicy) - if err != nil { - return nil, err + allRegisteredResourceValuesByFQN := options.registeredResourceValues + if allRegisteredResourceValuesByFQN == nil { + allRegisteredResourceValuesByFQN, err = buildRegisteredResourceValuesByFQN(allRegisteredResources, namespacedPolicy) + if err != nil { + return nil, err + } } pdp := &PolicyDecisionPoint{ diff --git a/service/internal/access/v2/prepared_policy.go b/service/internal/access/v2/prepared_policy.go new file mode 100644 index 0000000000..e0c4b81427 --- /dev/null +++ b/service/internal/access/v2/prepared_policy.go @@ -0,0 +1,113 @@ +package access + +import ( + "context" + "fmt" + + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/internal/access/v2/obligations" + "github.com/opentdf/platform/service/logger" +) + +// PolicyOptions controls how a policy snapshot is compiled. +type PolicyOptions struct { + AllowDirectEntitlements bool + AllowDynamicValueMappings bool + NamespacedPolicy bool +} + +// PreparedPolicy is immutable after construction and can be shared by requests. +type PreparedPolicy struct { + options PolicyOptions + registeredResources []*policy.RegisteredResource + registeredResourceValuesByFQN map[string]*policy.RegisteredResourceValue + dynamicValueMappings []*policy.DynamicValueMapping + obligationsPDP *obligations.ObligationsPolicyDecisionPoint + fullPolicyPDP *PolicyDecisionPoint +} + +// PreparedPolicyStore optionally supplies already compiled policy to the JIT PDP. +type PreparedPolicyStore interface { + PreparedPolicy(context.Context) (*PreparedPolicy, error) +} + +// NewPreparedPolicy validates and indexes the policy that does not depend on a request. +func NewPreparedPolicy(ctx context.Context, log *logger.Logger, store EntitlementPolicyStore, options PolicyOptions) (*PreparedPolicy, error) { + prepared := &PreparedPolicy{options: options} + // Attributes and subject mappings are fetched per request (targeted), so they are no longer + // loaded here. Registered resources, obligations, and (gated) dynamic value mappings remain + // fully loaded because they are not covered by GetEntitleableAttributesByFqns. + allRegisteredResources, err := store.ListAllRegisteredResources(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch all registered resources: %w", err) + } + allObligations, err := store.ListAllObligations(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch all obligations: %w", err) + } + // Experimental: only load dynamic value mappings when the feature is enabled. + if options.AllowDynamicValueMappings { + prepared.dynamicValueMappings, err = store.ListAllDynamicValueMappings(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch all dynamic value mappings: %w", err) + } + } + prepared.registeredResources = allRegisteredResources + + registeredResourceValuesByFQN, err := buildRegisteredResourceValuesByFQN(allRegisteredResources, options.NamespacedPolicy) + if err != nil { + return nil, fmt.Errorf("failed to index registered resources: %w", err) + } + prepared.registeredResourceValuesByFQN = registeredResourceValuesByFQN + + // Obligations are triggered by (action, attribute value FQN, PEP client) against a trigger graph + // built from all obligations; the attributes-by-value map is unused by the obligations PDP, so an + // empty map is passed. + obligationsPDP, err := obligations.NewObligationsPolicyDecisionPoint( + ctx, + log, + make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + registeredResourceValuesByFQN, + allObligations, + ) + if err != nil { + return nil, fmt.Errorf("failed to create new obligations policy decision point: %w", err) + } + prepared.obligationsPDP = obligationsPDP + + // Direct entitlements and dynamic value mappings entitle attribute values that may not exist in + // policy; synthesizing them requires the full definition set, which targeted + // GetEntitleableAttributesByFqns lookups cannot supply (a non-existent value FQN errors). When + // either experimental feature is enabled, build the PDP from the full policy load instead. + if options.AllowDirectEntitlements || options.AllowDynamicValueMappings { + // Read attributes and subject mappings from the same store used above (the refresh cache when + // ready, otherwise the live retriever), so a cache-enabled deployment does not re-scan both + // policy endpoints on every request. + allAttributes, err := store.ListAllAttributes(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list attributes: %w", err) + } + allSubjectMappings, err := store.ListAllSubjectMappings(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list subject mappings: %w", err) + } + fullPolicyPDP, err := NewPolicyDecisionPoint( + ctx, + log, + allAttributes, + allSubjectMappings, + allRegisteredResources, + options.AllowDirectEntitlements, + options.NamespacedPolicy, + WithDynamicValueMappings(prepared.dynamicValueMappings, options.AllowDynamicValueMappings), + withRegisteredResourceValues(registeredResourceValuesByFQN), + ) + if err != nil { + return nil, fmt.Errorf("failed to create full-policy decision point: %w", err) + } + prepared.fullPolicyPDP = fullPolicyPDP + } + + return prepared, nil +}