diff --git a/service/authorization/v2/authorization.go b/service/authorization/v2/authorization.go index 0e633dde9..a4e619c3f 100644 --- a/service/authorization/v2/authorization.go +++ b/service/authorization/v2/authorization.go @@ -12,9 +12,11 @@ import ( "github.com/go-viper/mapstructure/v2" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" authzV2Connect "github.com/opentdf/platform/protocol/go/authorization/v2/authorizationv2connect" + "github.com/opentdf/platform/protocol/go/policy" otdf "github.com/opentdf/platform/sdk" "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" @@ -23,6 +25,13 @@ import ( "google.golang.org/protobuf/types/known/wrapperspb" ) +var ( + ErrFailedToBuildRequestContext = errors.New("failed to contextualize decision request") + ErrFailedToInitPDP = errors.New("failed to create JIT PDP") + ErrFailedToGetDecision = errors.New("failed to get decision") + ErrFailedToGetEntitlements = errors.New("failed to get entitlements") +) + type Service struct { sdk *otdf.SDK config *Config @@ -138,15 +147,12 @@ func (as *Service) GetEntitlements(ctx context.Context, req *connect.Request[aut // When authorization service can consume cached policy, switch to the other PDP (process based on policy passed in) pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache) if err != nil { - as.logger.ErrorContext(ctx, "failed to create JIT PDP", slog.Any("error", err)) - return nil, connect.NewError(connect.CodeInternal, err) + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetEntitlements, ErrFailedToInitPDP, err)) } entitlements, err := pdp.GetEntitlements(ctx, entityIdentifier, withComprehensiveHierarchy) if err != nil { - // TODO: any bad request errors that aren't 500s? - as.logger.ErrorContext(ctx, "failed to get entitlements", slog.Any("error", err)) - return nil, connect.NewError(connect.CodeInternal, err) + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetEntitlements, err)) } rsp := &authzV2.GetEntitlementsResponse{ Entitlements: entitlements, @@ -168,27 +174,34 @@ func (as *Service) GetDecision(ctx context.Context, req *connect.Request[authzV2 pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache) if err != nil { - as.logger.ErrorContext(ctx, "failed to create JIT PDP", slog.Any("error", err)) - return nil, connect.NewError(connect.CodeInternal, err) + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } request := req.Msg entityIdentifier := request.GetEntityIdentifier() action := request.GetAction() resource := request.GetResource() + fulfillableObligations := request.GetFulfillableObligationFqns() - decisions, permitted, err := pdp.GetDecision(ctx, entityIdentifier, action, []*authzV2.Resource{resource}) + reqContext, err := as.getDecisionRequestContext(ctx) if err != nil { - as.logger.ErrorContext(ctx, "failed to get decision", slog.Any("error", err)) - if errors.Is(err, access.ErrFQNNotFound) || errors.Is(err, access.ErrDefinitionNotFound) { - return nil, connect.NewError(connect.CodeNotFound, err) - } - return nil, connect.NewError(connect.CodeInternal, err) + return nil, statusifyError(ctx, as.logger, err) + } + + decisions, permitted, err := pdp.GetDecision( + ctx, + entityIdentifier, + action, + []*authzV2.Resource{resource}, + reqContext, + fulfillableObligations, + ) + if err != nil { + return nil, statusifyError(ctx, as.logger, err) } resp, err := rollupSingleResourceDecision(permitted, decisions) if err != nil { - as.logger.ErrorContext(ctx, "failed to rollup single-resource decision", slog.Any("error", err)) - return nil, connect.NewError(connect.CodeInternal, err) + return nil, connect.NewError(connect.CodeInternal, errors.Join(ErrFailedToRollupDecision, err)) } return connect.NewResponse(resp), nil } @@ -206,21 +219,34 @@ func (as *Service) GetDecisionMultiResource(ctx context.Context, req *connect.Re pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache) if err != nil { - return nil, statusifyError(ctx, as.logger, errors.Join(errors.New("failed to create JIT PDP"), err)) + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } request := req.Msg entityIdentifier := request.GetEntityIdentifier() action := request.GetAction() resources := request.GetResources() + fulfillableObligations := request.GetFulfillableObligationFqns() - decisions, allPermitted, err := pdp.GetDecision(ctx, entityIdentifier, action, resources) + reqContext, err := as.getDecisionRequestContext(ctx) if err != nil { - return nil, statusifyError(ctx, as.logger, errors.Join(errors.New("failed to get decision"), err)) + return nil, statusifyError(ctx, as.logger, err) + } + + decisions, allPermitted, err := pdp.GetDecision( + ctx, + entityIdentifier, + action, + resources, + reqContext, + fulfillableObligations, + ) + if err != nil { + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetDecision, err)) } resourceDecisions, err := rollupMultiResourceDecisions(decisions) if err != nil { - return nil, statusifyError(ctx, as.logger, errors.Join(errors.New("failed to rollup multi-resource decision"), err)) + return nil, connect.NewError(connect.CodeInternal, err) } resp := &authzV2.GetDecisionMultiResourceResponse{ @@ -246,27 +272,33 @@ func (as *Service) GetDecisionBulk(ctx context.Context, req *connect.Request[aut pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache) if err != nil { - return nil, statusifyError(ctx, as.logger, errors.Join(errors.New("failed to create JIT PDP"), err)) + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } multiRequests := req.Msg.GetDecisionRequests() decisionResponses := make([]*authzV2.GetDecisionMultiResourceResponse, len(multiRequests)) + reqContext, err := as.getDecisionRequestContext(ctx) + if err != nil { + return nil, statusifyError(ctx, as.logger, err) + } + // TODO: revisit performance of this loop after introduction of caching and registered resource values within decisioning, // as the same entity in multiple requests should only be resolved JIT once, not once per request if the same in each. for idx, request := range multiRequests { entityIdentifier := request.GetEntityIdentifier() action := request.GetAction() resources := request.GetResources() + fulfillableObligations := request.GetFulfillableObligationFqns() - decisions, allPermitted, err := pdp.GetDecision(ctx, entityIdentifier, action, resources) + decisions, allPermitted, err := pdp.GetDecision(ctx, entityIdentifier, action, resources, reqContext, fulfillableObligations) if err != nil { - return nil, statusifyError(ctx, as.logger, errors.Join(errors.New("failed to get bulk decision"), err)) + return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetDecision, err)) } resourceDecisions, err := rollupMultiResourceDecisions(decisions) if err != nil { - return nil, statusifyError(ctx, as.logger, errors.Join(errors.New("failed to rollup bulk multi-resource decision"), err), slog.Int("index", idx)) + return nil, statusifyError(ctx, as.logger, err, slog.Int("index", idx)) } decisionResponse := &authzV2.GetDecisionMultiResourceResponse{ @@ -283,3 +315,16 @@ func (as *Service) GetDecisionBulk(ctx context.Context, req *connect.Request[aut } return connect.NewResponse(rsp), nil } + +// Builds a decision request context out of contextual metadata for the downstream obligation trigger/fulfillment decisioning +func (as *Service) getDecisionRequestContext(ctx context.Context) (*policy.RequestContext, error) { + clientID, err := ctxAuth.GetClientIDFromContext(ctx) + if err != nil { + return nil, errors.Join(ErrFailedToBuildRequestContext, err) + } + return &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: clientID, + }, + }, nil +} diff --git a/service/authorization/v2/authorization_test.go b/service/authorization/v2/authorization_test.go index cb6439791..567bb2035 100644 --- a/service/authorization/v2/authorization_test.go +++ b/service/authorization/v2/authorization_test.go @@ -1,7 +1,6 @@ package authorization import ( - "errors" "math/rand" "strconv" "testing" @@ -1286,12 +1285,11 @@ func Test_GetEntitlementsRequest_Fails(t *testing.T) { func Test_RollupSingleResourceDecision(t *testing.T) { tests := []struct { - name string - permitted bool - decisions []*access.Decision - expectedResult *authzV2.GetDecisionResponse - expectedError error - errorMsgContain string + name string + permitted bool + decisions []*access.Decision + expectedResult *authzV2.GetDecisionResponse + expectedError error }{ { name: "should return permit decision when permitted is true", @@ -1314,6 +1312,33 @@ func Test_RollupSingleResourceDecision(t *testing.T) { }, expectedError: nil, }, + { + name: "should surface obligations in a permit decision", + permitted: true, + decisions: []*access.Decision{ + { + Access: true, + Results: []access.ResourceDecision{ + { + ResourceID: "resource-123", + RequiredObligationValueFQNs: []string{ + "obligation-abc", + }, + }, + }, + }, + }, + expectedResult: &authzV2.GetDecisionResponse{ + Decision: &authzV2.ResourceDecision{ + Decision: authzV2.Decision_DECISION_PERMIT, + EphemeralResourceId: "resource-123", + RequiredObligations: []string{ + "obligation-abc", + }, + }, + }, + expectedError: nil, + }, { name: "should return deny decision when permitted is false", permitted: false, @@ -1336,12 +1361,34 @@ func Test_RollupSingleResourceDecision(t *testing.T) { expectedError: nil, }, { - name: "should return error when no decisions are provided", - permitted: true, - decisions: []*access.Decision{}, - expectedResult: nil, - expectedError: errors.New("no decisions returned"), - errorMsgContain: "no decisions returned", + name: "should surface obligations within a deny decision", + permitted: false, + decisions: []*access.Decision{ + { + Access: true, // Verify permitted takes precedence + Results: []access.ResourceDecision{ + { + ResourceID: "resource-123", + RequiredObligationValueFQNs: []string{"obligation-123"}, + }, + }, + }, + }, + expectedResult: &authzV2.GetDecisionResponse{ + Decision: &authzV2.ResourceDecision{ + Decision: authzV2.Decision_DECISION_DENY, + EphemeralResourceId: "resource-123", + RequiredObligations: []string{"obligation-123"}, + }, + }, + expectedError: nil, + }, + { + name: "should return error when no decisions are provided", + permitted: true, + decisions: []*access.Decision{}, + expectedResult: nil, + expectedError: ErrNoDecisions, }, { name: "should return error when decision has no results", @@ -1352,9 +1399,8 @@ func Test_RollupSingleResourceDecision(t *testing.T) { Results: []access.ResourceDecision{}, }, }, - expectedResult: nil, - expectedError: errors.New("no decision results returned"), - errorMsgContain: "no decision results returned", + expectedResult: nil, + expectedError: ErrDecisionMustHaveResults, }, } @@ -1364,11 +1410,11 @@ func Test_RollupSingleResourceDecision(t *testing.T) { if tc.expectedError != nil { require.Error(t, err) - assert.Contains(t, err.Error(), tc.errorMsgContain) + require.ErrorIs(t, err, tc.expectedError) assert.Nil(t, result) } else { require.NoError(t, err) - assert.Equal(t, tc.expectedResult, result) + assert.True(t, proto.Equal(tc.expectedResult, result)) } }) } @@ -1376,11 +1422,10 @@ func Test_RollupSingleResourceDecision(t *testing.T) { func Test_RollupMultiResourceDecisions(t *testing.T) { tests := []struct { - name string - decisions []*access.Decision - expectedResult []*authzV2.ResourceDecision - expectedError error - errorMsgContain string + name string + decisions []*access.Decision + expectedResult []*authzV2.ResourceDecision + expectedError error }{ { name: "should return multiple permit decisions", @@ -1414,7 +1459,6 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { EphemeralResourceId: "resource-456", }, }, - expectedError: nil, }, { name: "should return mix of permit and deny decisions", @@ -1448,7 +1492,6 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { EphemeralResourceId: "resource-456", }, }, - expectedError: nil, }, { name: "should rely on results and default to false decisions", @@ -1490,7 +1533,6 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { EphemeralResourceId: "resource-456", }, }, - expectedError: nil, }, { name: "should ignore global access and care about resource decisions predominantly", @@ -1532,7 +1574,77 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { EphemeralResourceId: "resource-456", }, }, - expectedError: nil, + }, + { + name: "should return obligations whenever found on a resource", + decisions: []*access.Decision{ + { + Access: true, + Results: []access.ResourceDecision{ + { + Passed: true, + ResourceID: "resource-123", + RequiredObligationValueFQNs: []string{ + "obligation-123", + "obligation-abc", + "obligation-456", + }, + }, + { + Passed: true, + ResourceID: "resource-abc", + RequiredObligationValueFQNs: []string{ + "obligation-abc", + }, + }, + }, + }, + { + Access: false, + Results: []access.ResourceDecision{ + { + Passed: false, + ResourceID: "resource-456", + }, + { + Passed: true, + ResourceID: "resource-extra", + RequiredObligationValueFQNs: []string{ + "obligation-extra", + }, + }, + }, + }, + }, + expectedResult: []*authzV2.ResourceDecision{ + { + Decision: authzV2.Decision_DECISION_PERMIT, + EphemeralResourceId: "resource-123", + RequiredObligations: []string{ + "obligation-123", + "obligation-abc", + "obligation-456", + }, + }, + { + Decision: authzV2.Decision_DECISION_PERMIT, + EphemeralResourceId: "resource-abc", + RequiredObligations: []string{ + "obligation-abc", + }, + }, + { + Decision: authzV2.Decision_DECISION_DENY, + EphemeralResourceId: "resource-456", + }, + { + Decision: authzV2.Decision_DECISION_PERMIT, + EphemeralResourceId: "resource-extra", + RequiredObligations: []string{ + "obligation-extra", + }, + }, + }, }, { name: "should return error when decision has no results", @@ -1542,9 +1654,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { Results: []access.ResourceDecision{}, }, }, - expectedResult: nil, - expectedError: errors.New("no decision results returned"), - errorMsgContain: "no decision results returned", + expectedError: ErrDecisionMustHaveResults, }, } @@ -1554,11 +1664,14 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { if tc.expectedError != nil { require.Error(t, err) - assert.Contains(t, err.Error(), tc.errorMsgContain) + require.ErrorIs(t, err, tc.expectedError) assert.Nil(t, result) } else { require.NoError(t, err) - assert.Equal(t, tc.expectedResult, result) + // resource order preserved + for i, decision := range result { + assert.True(t, proto.Equal(tc.expectedResult[i], decision)) + } } }) } @@ -1590,14 +1703,14 @@ func Test_RollupMultiResourceDecisions_WithNilChecks(t *testing.T) { var decisions []*access.Decision _, err := rollupMultiResourceDecisions(decisions) require.Error(t, err) - assert.Contains(t, err.Error(), "no decisions returned") + require.ErrorIs(t, err, ErrNoDecisions) }) t.Run("nil decision in array", func(t *testing.T) { decisions := []*access.Decision{nil} _, err := rollupMultiResourceDecisions(decisions) require.Error(t, err) - assert.Contains(t, err.Error(), "nil decision at index 0") + require.ErrorIs(t, err, ErrDecisionCannotBeNil) }) t.Run("nil Results field", func(t *testing.T) { @@ -1609,7 +1722,7 @@ func Test_RollupMultiResourceDecisions_WithNilChecks(t *testing.T) { } _, err := rollupMultiResourceDecisions(decisions) require.Error(t, err) - assert.Contains(t, err.Error(), "no decision results returned") + require.ErrorIs(t, err, ErrDecisionMustHaveResults) }) } @@ -1618,14 +1731,14 @@ func Test_RollupSingleResourceDecision_WithNilChecks(t *testing.T) { var decisions []*access.Decision _, err := rollupSingleResourceDecision(true, decisions) require.Error(t, err) - assert.Contains(t, err.Error(), "no decisions returned") + require.ErrorIs(t, err, ErrNoDecisions) }) t.Run("nil decision in array", func(t *testing.T) { decisions := []*access.Decision{nil} _, err := rollupSingleResourceDecision(true, decisions) require.Error(t, err) - assert.Contains(t, err.Error(), "nil decision at index 0") + require.ErrorIs(t, err, ErrDecisionCannotBeNil) }) t.Run("nil Results field", func(t *testing.T) { @@ -1637,7 +1750,7 @@ func Test_RollupSingleResourceDecision_WithNilChecks(t *testing.T) { } _, err := rollupSingleResourceDecision(true, decisions) require.Error(t, err) - assert.Contains(t, err.Error(), "no decision results returned") + require.ErrorIs(t, err, ErrDecisionMustHaveResults) }) } diff --git a/service/authorization/v2/cache.go b/service/authorization/v2/cache.go index 27baebffd..2d20ce13b 100644 --- a/service/authorization/v2/cache.go +++ b/service/authorization/v2/cache.go @@ -17,6 +17,7 @@ const ( attributesCacheKey = "attributes_cache_key" subjectMappingsCacheKey = "subject_mappings_cache_key" registeredResourcesCacheKey = "registered_resources_cache_key" + obligationsCacheKey = "obligations_cache_key" ) var ( @@ -62,6 +63,7 @@ type EntitlementPolicy struct { Attributes []*policy.Attribute SubjectMappings []*policy.SubjectMapping RegisteredResources []*policy.RegisteredResource + Obligations []*policy.Obligation } // NewEntitlementPolicyCache holds a platform-provided cache client and manages a periodic refresh of @@ -180,6 +182,10 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { 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) @@ -200,11 +206,18 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { return errors.Join(ErrFailedToSet, err) } + err = c.cacheClient.Set(ctx, obligationsCacheKey, obligations, authzCacheTags) + if err != nil { + c.isCacheFilled = false + return errors.Join(ErrFailedToSet, err) + } + 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)), ) // Mark the cache as filled after a successful refresh @@ -279,6 +292,28 @@ func (c *EntitlementPolicyCache) ListAllRegisteredResources(ctx context.Context) return registeredResources, nil } +// 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 + ) + + cached, err := c.cacheClient.Get(ctx, obligationsCacheKey) + if err != nil { + if errors.Is(err, cache.ErrCacheMiss) { + return obligations, nil + } + return nil, fmt.Errorf("%w, obligations: %w", ErrFailedToGet, err) + } + + obligations, ok = cached.([]*policy.Obligation) + if !ok { + return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, obligations) + } + return obligations, nil +} + // periodicRefresh refreshes the cache at the specified interval func (c *EntitlementPolicyCache) periodicRefresh(ctx context.Context) { waitTimeout := c.configuredRefreshInterval diff --git a/service/authorization/v2/helpers.go b/service/authorization/v2/helpers.go index 0565a63e3..d8df45ffc 100644 --- a/service/authorization/v2/helpers.go +++ b/service/authorization/v2/helpers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "connectrpc.com/connect" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -11,23 +12,31 @@ import ( "github.com/opentdf/platform/service/logger" ) +var ( + ErrFailedToRollupDecision = errors.New("failed to rollup decision") + ErrResponseSafeInternalError = errors.New("an unexpected error occurred") + ErrNoDecisions = errors.New("no decisions returned") + ErrDecisionCannotBeNil = errors.New("decision cannot be nil") + ErrDecisionMustHaveResults = errors.New("decision must have results") +) + // rollupMultiResourceDecisions creates a standardized response for multi-resource decisions // by processing the decisions returned from the PDP. func rollupMultiResourceDecisions( decisions []*access.Decision, ) ([]*authzV2.ResourceDecision, error) { if len(decisions) == 0 { - return nil, errors.New("no decisions returned") + return nil, errors.Join(ErrFailedToRollupDecision, ErrNoDecisions) } var resourceDecisions []*authzV2.ResourceDecision for idx, decision := range decisions { if decision == nil { - return nil, fmt.Errorf("nil decision at index %d", idx) + return nil, errors.Join(ErrFailedToRollupDecision, fmt.Errorf("%w: index %d", ErrDecisionCannotBeNil, idx)) } if len(decision.Results) == 0 { - return nil, errors.New("no decision results returned") + return nil, errors.Join(ErrFailedToRollupDecision, fmt.Errorf("%w: %+v", ErrDecisionMustHaveResults, decision)) } for _, result := range decision.Results { access := authzV2.Decision_DECISION_DENY @@ -37,6 +46,7 @@ func rollupMultiResourceDecisions( resourceDecision := &authzV2.ResourceDecision{ Decision: access, EphemeralResourceId: result.ResourceID, + RequiredObligations: result.RequiredObligationValueFQNs, } resourceDecisions = append(resourceDecisions, resourceDecision) } @@ -52,16 +62,16 @@ func rollupSingleResourceDecision( decisions []*access.Decision, ) (*authzV2.GetDecisionResponse, error) { if len(decisions) == 0 { - return nil, errors.New("no decisions returned") + return nil, errors.Join(ErrFailedToRollupDecision, ErrNoDecisions) } decision := decisions[0] if decision == nil { - return nil, errors.New("nil decision at index 0") + return nil, errors.Join(ErrFailedToRollupDecision, ErrDecisionCannotBeNil) } if len(decision.Results) == 0 { - return nil, errors.New("no decision results returned") + return nil, errors.Join(ErrFailedToRollupDecision, fmt.Errorf("%w: %+v", ErrDecisionMustHaveResults, decision)) } result := decision.Results[0] @@ -72,6 +82,7 @@ func rollupSingleResourceDecision( resourceDecision := &authzV2.ResourceDecision{ Decision: access, EphemeralResourceId: result.ResourceID, + RequiredObligations: result.RequiredObligationValueFQNs, } return &authzV2.GetDecisionResponse{ Decision: resourceDecision, @@ -80,7 +91,7 @@ func rollupSingleResourceDecision( // Checks for known error types and returns standardized error codes and messages func statusifyError(ctx context.Context, l *logger.Logger, err error, logs ...any) error { - l = l.With("error", err.Error()) + logs = append(logs, slog.Any("error", err)) if errors.Is(err, access.ErrFQNNotFound) { l.ErrorContext(ctx, "FQN not found", logs...) return connect.NewError(connect.CodeNotFound, err) @@ -90,5 +101,7 @@ func statusifyError(ctx context.Context, l *logger.Logger, err error, logs ...an return connect.NewError(connect.CodeNotFound, err) } l.ErrorContext(ctx, "unexpected error", logs...) - return connect.NewError(connect.CodeInternal, err) + + // Ensure error response is safe and does not leak internal information + return connect.NewError(connect.CodeInternal, ErrResponseSafeInternalError) } diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index acfae78b0..f7de06625 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -15,6 +15,7 @@ import ( "github.com/opentdf/platform/protocol/go/policy/subjectmapping" otdfSDK "github.com/opentdf/platform/sdk" + "github.com/opentdf/platform/service/internal/access/v2/obligations" "github.com/opentdf/platform/service/logger" ) @@ -26,15 +27,17 @@ var ( type JustInTimePDP struct { logger *logger.Logger sdk *otdfSDK.SDK - // embedded PDP + // embedded entitlement PDP pdp *PolicyDecisionPoint + // embedded obligations PDP + obligationsPDP *obligations.ObligationsPolicyDecisionPoint } // JustInTimePDP creates a new Policy Decision Point instance with no in-memory policy and a remote connection // via authenticated SDK, then fetches all entitlement policy from provided store interface or policy services directly. func NewJustInTimePDP( ctx context.Context, - l *logger.Logger, + log *logger.Logger, sdk *otdfSDK.SDK, store EntitlementPolicyStore, ) (*JustInTimePDP, error) { @@ -43,8 +46,8 @@ func NewJustInTimePDP( if sdk == nil { return nil, ErrMissingRequiredSDK } - if l == nil { - l, err = logger.NewLogger(defaultFallbackLoggerConfig) + if log == nil { + log, err = logger.NewLogger(defaultFallbackLoggerConfig) if err != nil { return nil, fmt.Errorf("failed to initialize new PDP logger and none was provided: %w", err) } @@ -52,12 +55,12 @@ func NewJustInTimePDP( p := &JustInTimePDP{ sdk: sdk, - logger: l, + logger: log, } // If no store is provided, have EntitlementPolicyRetriever fetch from policy services if !store.IsEnabled() || !store.IsReady(ctx) { - l.DebugContext(ctx, "no EntitlementPolicyStore provided or not yet ready, will retrieve directly from policy services") + log.DebugContext(ctx, "no EntitlementPolicyStore provided or not yet ready, will retrieve directly from policy services") store = NewEntitlementPolicyRetriever(sdk) } @@ -73,12 +76,29 @@ func NewJustInTimePDP( 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) + } - pdp, err := NewPolicyDecisionPoint(ctx, l, allAttributes, allSubjectMappings, allRegisteredResources) + pdp, err := NewPolicyDecisionPoint(ctx, log, allAttributes, allSubjectMappings, allRegisteredResources) if err != nil { return nil, fmt.Errorf("failed to create new policy decision point: %w", err) } p.pdp = pdp + + obligationsPDP, err := obligations.NewObligationsPolicyDecisionPoint( + ctx, + log, + pdp.allEntitleableAttributesByValueFQN, + pdp.allRegisteredResourceValuesByFQN, + allObligations, + ) + if err != nil { + return nil, fmt.Errorf("failed to create new obligations policy decision point: %w", err) + } + p.obligationsPDP = obligationsPDP + return p, nil } @@ -86,11 +106,18 @@ func NewJustInTimePDP( // It resolves the entity chain to get the entity representations and then calls the embedded PDP to get the decision. // The decision is returned as a slice of Decision objects, along with a global boolean indicating whether or not all // decisions are allowed. +// +// IFF the entity was entitled to take the action on all resources, the PEP's fulfillable obligations are checked against +// those triggered by the actions, attributes, and the decision request context. Since the entity was entitled, the Decision +// returned indicates the obligations required whether or not they were satisfied by the fulfillable obligations. This allows +// a PEP to take corrective action. func (p *JustInTimePDP) GetDecision( ctx context.Context, entityIdentifier *authzV2.EntityIdentifier, action *policy.Action, resources []*authzV2.Resource, + requestContext *policy.RequestContext, + fulfillableObligationValueFQNs []string, ) ([]*Decision, bool, error) { var ( entityRepresentations []*entityresolutionV2.EntityRepresentation @@ -98,6 +125,18 @@ func (p *JustInTimePDP) GetDecision( skipEnvironmentEntities = true ) + // Because there are three possible types of entities, check obligations first to more easily handle decisioning logic + allTriggeredObligationsCanBeFulfilled, requiredObligationsPerResource, err := p.obligationsPDP.GetAllTriggeredObligationsAreFulfilled( + ctx, + resources, + action, + requestContext, + fulfillableObligationValueFQNs, + ) + if err != nil { + return nil, false, fmt.Errorf("failed to check obligations: %w", err) + } + switch entityIdentifier.GetIdentifier().(type) { case *authzV2.EntityIdentifier_EntityChain: entityRepresentations, err = p.resolveEntitiesFromEntityChain(ctx, entityIdentifier.GetEntityChain(), skipEnvironmentEntities) @@ -107,7 +146,7 @@ func (p *JustInTimePDP) GetDecision( case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: regResValueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) - // registered resources do not have entity representations, so only one decision to make and we can skip the remaining logic + // Registered resources do not have entity representations, so only one decision is made decision, err := p.pdp.GetDecisionRegisteredResource(ctx, regResValueFQN, action, resources) if err != nil { return nil, false, fmt.Errorf("failed to get decision for registered resource value FQN [%s]: %w", regResValueFQN, err) @@ -115,6 +154,18 @@ func (p *JustInTimePDP) GetDecision( if decision == nil { return nil, false, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN) } + + // If not entitled, obligations are not considered + if !decision.Access { + return []*Decision{decision}, decision.Access, nil + } + + // Access should only be granted if entitled AND obligations fulfilled + decision.Access = allTriggeredObligationsCanBeFulfilled + for idx, required := range requiredObligationsPerResource { + decision.Results[idx].RequiredObligationValueFQNs = required + } + return []*Decision{decision}, decision.Access, nil default: @@ -124,9 +175,10 @@ func (p *JustInTimePDP) GetDecision( return nil, false, fmt.Errorf("failed to resolve entity identifier: %w", err) } - var decisions []*Decision + // Make initial entitlement decisions + entityDecisions := make([]*Decision, len(entityRepresentations)) allPermitted := true - for _, entityRep := range entityRepresentations { + for idx, entityRep := range entityRepresentations { d, err := p.pdp.GetDecision(ctx, entityRep, action, resources) if err != nil { return nil, false, fmt.Errorf("failed to get decision for entityRepresentation with original id [%s]: %w", entityRep.GetOriginalId(), err) @@ -137,11 +189,24 @@ func (p *JustInTimePDP) GetDecision( if !d.Access { allPermitted = false } - // Decisions should be granular, so do not globally pass or fail - decisions = append(decisions, d) + entityDecisions[idx] = d + } + + // If even one entity was denied access, obligations are not considered or returned + if !allPermitted { + return entityDecisions, allPermitted, nil + } + + // Access should only be granted if entitled AND obligations fulfilled + allPermitted = allTriggeredObligationsCanBeFulfilled + // Obligations are not entity-specific at this time so will be the same across every entity + for _, decision := range entityDecisions { + for idx, required := range requiredObligationsPerResource { + decision.Results[idx].RequiredObligationValueFQNs = required + } } - return decisions, allPermitted, nil + return entityDecisions, allPermitted, nil } // GetEntitlements retrieves the entitlements for the provided entity chain. diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 0d5225f6c..1406bd431 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -26,10 +26,11 @@ type Decision struct { // ResourceDecision represents the result of evaluating the action on one resource for an entity. type ResourceDecision struct { - Passed bool `json:"passed" example:"false"` - ResourceID string `json:"resource_id,omitempty"` - ResourceName string `json:"resource_name,omitempty"` - DataRuleResults []DataRuleResult `json:"data_rule_results"` + Passed bool `json:"passed" example:"false"` + ResourceID string `json:"resource_id,omitempty"` + ResourceName string `json:"resource_name,omitempty"` + DataRuleResults []DataRuleResult `json:"data_rule_results"` + RequiredObligationValueFQNs []string `json:"required_obligation_value_fqns"` } // DataRuleResult represents the result of evaluating one rule for an entity. diff --git a/service/internal/access/v2/policy_store.go b/service/internal/access/v2/policy_store.go index 81181110d..d4f6e3986 100644 --- a/service/internal/access/v2/policy_store.go +++ b/service/internal/access/v2/policy_store.go @@ -7,6 +7,7 @@ import ( "github.com/opentdf/platform/protocol/go/common" "github.com/opentdf/platform/protocol/go/policy" attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/obligations" "github.com/opentdf/platform/protocol/go/policy/registeredresources" "github.com/opentdf/platform/protocol/go/policy/subjectmapping" otdfSDK "github.com/opentdf/platform/sdk" @@ -17,6 +18,7 @@ type EntitlementPolicyStore interface { ListAllAttributes(ctx context.Context) ([]*policy.Attribute, error) ListAllSubjectMappings(ctx context.Context) ([]*policy.SubjectMapping, error) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) + ListAllObligations(ctx context.Context) ([]*policy.Obligation, error) IsEnabled() bool IsReady(context.Context) bool } @@ -25,6 +27,7 @@ var ( ErrFailedToFetchAttributes = errors.New("failed to fetch attributes from policy service") ErrFailedToFetchSubjectMappings = errors.New("failed to fetch subject mappings from policy service") ErrFailedToFetchRegisteredResources = errors.New("failed to fetch registered resources from policy service") + ErrFailedToFetchObligations = errors.New("failed to fetch obligations from policy service") ) // EntitlementPolicyRetriever satisfies the EntitlementPolicyStore interface and fetches fresh @@ -126,3 +129,30 @@ func (p *EntitlementPolicyRetriever) ListAllRegisteredResources(ctx context.Cont return rrList, nil } + +func (p *EntitlementPolicyRetriever) ListAllObligations(ctx context.Context) ([]*policy.Obligation, error) { + // If quantity of obligationss exceeds maximum list pagination, all are needed to determine entitlements + var nextOffset int32 + obligationList := make([]*policy.Obligation, 0) + + for { + listed, err := p.SDK.Obligations.ListObligations(ctx, &obligations.ListObligationsRequest{ + // defer to service default for limit pagination + Pagination: &policy.PageRequest{ + Offset: nextOffset, + }, + }) + if err != nil { + return nil, errors.Join(ErrFailedToFetchObligations, err) + } + + nextOffset = listed.GetPagination().GetNextOffset() + obligationList = append(obligationList, listed.GetObligations()...) + + if nextOffset <= 0 { + break + } + } + + return obligationList, nil +} diff --git a/service/pkg/auth/context_auth.go b/service/pkg/auth/context_auth.go index f4d857a77..28eab535d 100644 --- a/service/pkg/auth/context_auth.go +++ b/service/pkg/auth/context_auth.go @@ -13,8 +13,8 @@ import ( var ( authnContextKey = authContextKey{} ErrNoMetadataFound = errors.New("no metadata found within context") - ErrMissingClientID = errors.New("context metadata missing authn idP clientID that should have been set by interceptor") - ErrConflictClientID = errors.New("context metadata has more than one authn idP clientID and should only ever have one") + ErrMissingClientID = errors.New("missing authn idP clientID") + ErrConflictClientID = errors.New("context metadata mistakenly has more than one authn idP clientID") ) const (