From 677129d7dd499066f858c15720378a8c5f204568 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 20 May 2025 12:49:43 -0700 Subject: [PATCH 01/18] feat(authz): access pdp v2 with actions --- service/internal/access/pdp.go | 11 +- service/internal/access/v2/errors.go | 22 + service/internal/access/v2/evaluate.go | 312 +++ service/internal/access/v2/evaluate_test.go | 811 +++++++ service/internal/access/v2/helpers.go | 178 ++ service/internal/access/v2/helpers_test.go | 570 +++++ .../internal/access/v2/just_in_time_pdp.go | 333 +++ service/internal/access/v2/pdp.go | 317 +++ service/internal/access/v2/pdp_test.go | 1962 +++++++++++++++++ service/internal/access/v2/validators.go | 133 ++ service/internal/access/v2/validators_test.go | 427 ++++ 11 files changed, 5073 insertions(+), 3 deletions(-) create mode 100644 service/internal/access/v2/errors.go create mode 100644 service/internal/access/v2/evaluate.go create mode 100644 service/internal/access/v2/evaluate_test.go create mode 100644 service/internal/access/v2/helpers.go create mode 100644 service/internal/access/v2/helpers_test.go create mode 100644 service/internal/access/v2/just_in_time_pdp.go create mode 100644 service/internal/access/v2/pdp.go create mode 100644 service/internal/access/v2/pdp_test.go create mode 100644 service/internal/access/v2/validators.go create mode 100644 service/internal/access/v2/validators_test.go diff --git a/service/internal/access/pdp.go b/service/internal/access/pdp.go index 137058c726..54ccff2acc 100644 --- a/service/internal/access/pdp.go +++ b/service/internal/access/pdp.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "strings" "github.com/opentdf/platform/protocol/go/policy" @@ -156,17 +157,21 @@ func (pdp *Pdp) evaluateRule( distinctValues []*policy.Value, entityAttributeSets map[string][]string, ) (map[string]DataRuleResult, error) { + pdp.logger.DebugContext(ctx, + "Evaluating attribute definition", + slog.String("name", attrDefinition.GetFqn()), + slog.String("rule", attrDefinition.GetRule().String()), + slog.Any("values", distinctValues), + ) + switch attrDefinition.GetRule() { case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: - pdp.logger.DebugContext(ctx, "Evaluating under allOf", "name", attrDefinition.GetFqn()) return pdp.allOfRule(ctx, distinctValues, entityAttributeSets) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: - pdp.logger.DebugContext(ctx, "Evaluating under anyOf", "name", attrDefinition.GetFqn()) return pdp.anyOfRule(ctx, distinctValues, entityAttributeSets) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: - pdp.logger.DebugContext(ctx, "Evaluating under hierarchy", "name", attrDefinition.GetFqn()) return pdp.hierarchyRule(ctx, distinctValues, entityAttributeSets, attrDefinition.GetValues()) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: diff --git a/service/internal/access/v2/errors.go b/service/internal/access/v2/errors.go new file mode 100644 index 0000000000..ba40217385 --- /dev/null +++ b/service/internal/access/v2/errors.go @@ -0,0 +1,22 @@ +package access + +import "errors" + +var ( + ErrMissingRequiredSDK = errors.New("access: missing required SDK") + ErrMissingRequiredLogger = errors.New("access: missing required logger") + ErrMissingEntityResolutionServiceSDKConnection = errors.New("access: missing required entity resolution SDK connection, cannot be nil") + ErrMissingRequiredPolicy = errors.New("access: both attribute definitions and subject mappings must be provided or neither") + ErrInvalidEntityType = errors.New("access: invalid entity type") + ErrInvalidAttributeDefinition = errors.New("access: invalid attribute definition") + ErrInvalidSubjectMapping = errors.New("access: invalid subject mapping") + ErrInvalidEntitledFQNsToActions = errors.New("access: invalid entitled FQNs to actions") + ErrInvalidResource = errors.New("access: invalid resource") + ErrInvalidEntityChain = errors.New("access: invalid entity chain") + ErrInvalidAction = errors.New("access: invalid action") + ErrFQNNotFound = errors.New("access: attribute value FQN not found in memory") + ErrDefinitionNotFound = errors.New("access: definition not found for FQN") + ErrFailedEvaluation = errors.New("access: failed to evaluate definition") + ErrMissingRequiredSpecifiedRule = errors.New("access: AttributeDefinition rule cannot be unspecified") + ErrUnrecognizedRule = errors.New("access: unrecognized AttributeDefinition rule") +) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go new file mode 100644 index 0000000000..0781109257 --- /dev/null +++ b/service/internal/access/v2/evaluate.go @@ -0,0 +1,312 @@ +package access + +import ( + "context" + "fmt" + "log/slog" + "strings" + + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" + "github.com/opentdf/platform/service/logger" +) + +// getResourceDecision evaluates the access decision for a single resource, driving the flows +// between entitlement checks for the different types of resources +func getResourceDecision( + ctx context.Context, + logger *logger.Logger, + accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resource *authz.Resource, +) (*ResourceDecision, error) { + if err := validateGetResourceDecision(accessibleAttributeValues, entitlements, action, resource); err != nil { + return nil, err + } + + logger.DebugContext( + ctx, + "getting decision on one resource", + slog.Any("resource", resource.GetResource()), + ) + + switch resource.GetResource().(type) { + case *authz.Resource_RegisteredResourceValueFqn: + // TODO: handle registered resources + // return evaluateRegisteredResourceValue(ctx, resource.GetRegisteredResourceValueFqn(), action, entitlements, accessibleAttributeValues) + case *authz.Resource_AttributeValues_: + return evaluateResourceAttributeValues(ctx, logger, resource.GetAttributeValues(), resource.GetEphemeralId(), action, entitlements, accessibleAttributeValues) + + default: + return nil, fmt.Errorf("unsupported resource type: %w", ErrInvalidResource) + } + + // should never reach here + return nil, fmt.Errorf("unable to get resource resource decision: %w", ErrInvalidResource) +} + +// evaluateResourceAttributeValues evaluates a list of attribute values against the action and entitlements +func evaluateResourceAttributeValues( + ctx context.Context, + logger *logger.Logger, + resourceAttributeValues *authz.Resource_AttributeValues, + resourceID string, + action *policy.Action, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, +) (*ResourceDecision, error) { + // Group value FQNs by parent definition + groupedByDefinition := make(map[string][]string) + definitionsLookup := make(map[string]*policy.Attribute) + for idx, valueFQN := range resourceAttributeValues.GetFqns() { + // lowercase the value FQN to ensure case-insensitive matching + valueFQN = strings.ToLower(valueFQN) + resourceAttributeValues.Fqns[idx] = valueFQN + + attributeAndValue, okvalueFQN := accessibleAttributeValues[valueFQN] + if !okvalueFQN { + return nil, fmt.Errorf("%w: %s", ErrFQNNotFound, valueFQN) + } + definition := attributeAndValue.GetAttribute() + groupedByDefinition[definition.GetFqn()] = append(groupedByDefinition[definition.GetFqn()], valueFQN) + definitionsLookup[definition.GetFqn()] = definition + } + + // Evaluate each definition by rule, resource attributes, action, and entitlements + passed := true + dataRuleResults := make([]DataRuleResult, 0) + + for defFQN, valueFQNs := range groupedByDefinition { + definition := definitionsLookup[defFQN] + if definition == nil { + return nil, fmt.Errorf("%w: %s", ErrDefinitionNotFound, defFQN) + } + + dataRuleResult, err := evaluateDefinition(ctx, logger, entitlements, action, valueFQNs, definition) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrFailedEvaluation, err.Error()) + } + if !dataRuleResult.Passed { + passed = false + } + + dataRuleResults = append(dataRuleResults, *dataRuleResult) + } + + // Return results in the appropriate structure + return &ResourceDecision{ + Passed: passed, + ResourceID: resourceID, + DataRuleResults: dataRuleResults, + }, nil +} + +func evaluateDefinition( + ctx context.Context, + logger *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, + attrDefinition *policy.Attribute, +) (*DataRuleResult, error) { + var entitlementFailures []EntitlementFailure + + logger.DebugContext( + ctx, + "evaluating definition", + slog.String("definition rule", attrDefinition.GetRule().String()), + slog.String("definition FQN", attrDefinition.GetFqn()), + slog.Any("entitlements", entitlements), + slog.String("action", action.GetName()), + slog.Any("resource value FQNs", resourceValueFQNs), + ) + + switch attrDefinition.GetRule() { + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: + entitlementFailures = allOfRule(ctx, logger, entitlements, action, resourceValueFQNs) + + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: + entitlementFailures = anyOfRule(ctx, logger, entitlements, action, resourceValueFQNs) + + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: + entitlementFailures = hierarchyRule(ctx, logger, entitlements, action, resourceValueFQNs, attrDefinition) + + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: + return nil, fmt.Errorf("%w: %s, rule: %s", ErrMissingRequiredSpecifiedRule, attrDefinition.GetFqn(), attrDefinition.GetRule().String()) + default: + return nil, fmt.Errorf("%w: %s", ErrUnrecognizedRule, attrDefinition.GetRule().String()) + } + + result := &DataRuleResult{ + Passed: len(entitlementFailures) == 0, + RuleDefinition: attrDefinition, + } + if len(entitlementFailures) > 0 { + result.EntitlementFailures = entitlementFailures + } + return result, nil +} + +// allOfRule validates that: +// 1. For each resource attribute value FQN, the action is entitled +// 2. If any FQN is not entitled, or the FQN is missing the requested action, the rule fails +func allOfRule( + _ context.Context, + _ *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, +) []EntitlementFailure { + actionName := action.GetName() + failures := make([]EntitlementFailure, 0, len(resourceValueFQNs)) // Pre-allocate for efficiency + + // Single loop through all resource value FQNs + for _, valueFQN := range resourceValueFQNs { + hasEntitlement := false + + // Check if this FQN has the entitled action + if entitledActions, ok := entitlements[valueFQN]; ok { + for _, entitledAction := range entitledActions { + if strings.EqualFold(entitledAction.GetName(), actionName) { + hasEntitlement = true + break + } + } + } + + // If no entitlement found for this FQN, add to failures immediately + if !hasEntitlement { + failures = append(failures, EntitlementFailure{ + AttributeValueFQN: valueFQN, + ActionName: actionName, + }) + } + } + + return failures +} + +// anyOfRule validates that: +// 1. At least one resource attribute value FQN has the action entitled +// 2. If none of the FQNs are found the entitlements, the rule fails +// 3. If none of the matching FQNs in the entitlements contain the requested action, the rule fails +func anyOfRule( + _ context.Context, + _ *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, +) []EntitlementFailure { + // No resources to check + if len(resourceValueFQNs) == 0 { + return nil + } + + actionName := action.GetName() + anyEntitlementFound := false + entitlementFailures := make([]EntitlementFailure, 0, len(resourceValueFQNs)) + + // Single loop through all resource value FQNs + for _, valueFQN := range resourceValueFQNs { + foundEntitlementForThisFQN := false + + entitledActions, ok := entitlements[valueFQN] + if ok { + for _, entitledAction := range entitledActions { + if strings.EqualFold(entitledAction.GetName(), actionName) { + foundEntitlementForThisFQN = true + anyEntitlementFound = true + break + } + } + } + + if !foundEntitlementForThisFQN { + entitlementFailures = append(entitlementFailures, EntitlementFailure{ + AttributeValueFQN: valueFQN, + ActionName: actionName, + }) + } + } + + // Rule is satisfied if at least one FQN has the entitled action + if anyEntitlementFound { + return nil + } + return entitlementFailures +} + +// hierarchyRule validates that: +// 1. The user has entitlement to the specified action on the highest value FQN in the hierarchy or any hierarchically higher value +// 2. The highest value FQN is determined by the lowest index in the hierarchy definition +// 3. If the highest value FQN or any higher value has the required entitlement, the rule passes with no failures +// 4. If no hierarchically relevant FQN has the required entitlement, the rule fails with all missing entitlements +func hierarchyRule( + _ context.Context, + _ *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, + attrDefinition *policy.Attribute, +) []EntitlementFailure { + // No resources to check + if len(resourceValueFQNs) == 0 { + return nil + } + + actionName := action.GetName() + + // Create a lookup map for the attribute value indices - O(n) where n is the number of values in the attribute + valueFQNToIndex := make(map[string]int, len(attrDefinition.GetValues())) + for idx, value := range attrDefinition.GetValues() { + valueFQNToIndex[value.GetFqn()] = idx + } + + // Find the lowest indexed value FQN (highest in hierarchy) - O(m) where m is the number of resource values + lowestValueFQNIndex := len(attrDefinition.GetValues()) + for _, valueFQN := range resourceValueFQNs { + if idx, exists := valueFQNToIndex[valueFQN]; exists && idx < lowestValueFQNIndex { + lowestValueFQNIndex = idx + } + } + + // Check if the entitlements contain any values with index <= lowestValueFQNIndex + // This checks the requested value and any hierarchically higher values in a single pass - O(e) where e is entitlements count + for entitlementFQN, entitledActions := range entitlements { + // Check if this entitlement FQN has a valid index in the hierarchy + if idx, exists := valueFQNToIndex[entitlementFQN]; exists && idx <= lowestValueFQNIndex { + // Check if the required action is entitled + for _, entitledAction := range entitledActions { + if strings.EqualFold(entitledAction.GetName(), actionName) { + return nil // Found an entitled action at or above the hierarchy level, no failures + } + } + } + } + + // The rule was not satisfied - collect failures - O(m) where m is the number of resource values + entitlementFailures := make([]EntitlementFailure, 0, len(resourceValueFQNs)) + for _, valueFQN := range resourceValueFQNs { + foundValue := false + if entitledActions, ok := entitlements[valueFQN]; ok { + for _, entitledAction := range entitledActions { + if strings.EqualFold(entitledAction.GetName(), actionName) { + foundValue = true + break + } + } + } + + if !foundValue { + entitlementFailures = append(entitlementFailures, EntitlementFailure{ + AttributeValueFQN: valueFQN, + ActionName: actionName, + }) + } + } + + return entitlementFailures +} diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go new file mode 100644 index 0000000000..6609657300 --- /dev/null +++ b/service/internal/access/v2/evaluate_test.go @@ -0,0 +1,811 @@ +package access + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/suite" + + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/policy/actions" +) + +// Constants for namespaces and attribute FQNs +const ( + // Base namespaces + baseNamespace = "https://namespace.com" + classificationFQN = baseNamespace + "/attr/classification" + departmentFQN = baseNamespace + "/attr/department" + projectFQN = baseNamespace + "/attr/project" + + // Classification values + classTopSecretFQN = classificationFQN + "/value/topsecret" + classSecretFQN = classificationFQN + "/value/secret" + classConfidentialFQN = classificationFQN + "/value/confidential" + classRestrictedFQN = classificationFQN + "/value/restricted" + classPublicFQN = classificationFQN + "/value/public" + + // Department values + deptFinanceFQN = departmentFQN + "/value/finance" + deptMarketingFQN = departmentFQN + "/value/marketing" + deptLegalFQN = departmentFQN + "/value/legal" + + // Project values + projectJusticeLeagueFQN = projectFQN + "/value/justiceleague" + projectAvengersFQN = projectFQN + "/value/avengers" + projectXmenFQN = projectFQN + "/value/xmen" + projectFantasicFourFQN = projectFQN + "/value/fantasticfour" +) + +var ( + // Actions + actionRead = &policy.Action{Name: actions.ActionNameRead} + actionCreate = &policy.Action{Name: actions.ActionNameCreate} +) + +// EvaluateTestSuite is a test suite for the evaluate.go file functions +type EvaluateTestSuite struct { + suite.Suite + logger *logger.Logger + action *policy.Action + + // Common test data + hierarchicalClassAttr *policy.Attribute + allOfProjectAttr *policy.Attribute + anyOfDepartmentAttr *policy.Attribute + accessibleAttrValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue +} + +func (s *EvaluateTestSuite) SetupTest() { + s.logger = logger.CreateTestLogger() + s.action = actionRead + + // Setup classification attribute (HIERARCHY) + s.hierarchicalClassAttr = &policy.Attribute{ + Fqn: classificationFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: []*policy.Value{ + // highest in hierarchy + {Fqn: classTopSecretFQN, Value: "topsecret"}, + {Fqn: classSecretFQN, Value: "secret"}, + {Fqn: classConfidentialFQN, Value: "confidential"}, + {Fqn: classRestrictedFQN, Value: "restricted"}, + {Fqn: classPublicFQN, Value: "public"}, + // lowest in hierarchy + }, + } + + // Setup project attribute (ALL_OF) + s.allOfProjectAttr = &policy.Attribute{ + Fqn: projectFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{ + {Fqn: projectAvengersFQN, Value: "avengers"}, + {Fqn: projectJusticeLeagueFQN, Value: "justiceleague"}, + {Fqn: projectXmenFQN, Value: "xmen"}, + {Fqn: projectFantasicFourFQN, Value: "fantasticfour"}, + }, + } + + // Setup department attribute (ANY_OF) + s.anyOfDepartmentAttr = &policy.Attribute{ + Fqn: departmentFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{ + {Fqn: deptFinanceFQN, Value: "finance"}, + {Fqn: deptMarketingFQN, Value: "marketing"}, + }, + } + + // Setup accessible attribute values map + s.accessibleAttrValues = map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + classConfidentialFQN: { + Attribute: s.hierarchicalClassAttr, + Value: &policy.Value{Fqn: classConfidentialFQN}, + }, + classSecretFQN: { + Attribute: s.hierarchicalClassAttr, + Value: &policy.Value{Fqn: classSecretFQN}, + }, + classRestrictedFQN: { + Attribute: s.hierarchicalClassAttr, + Value: &policy.Value{Fqn: classRestrictedFQN}, + }, + classTopSecretFQN: { + Attribute: s.hierarchicalClassAttr, + Value: &policy.Value{Fqn: classTopSecretFQN}, + }, + classPublicFQN: { + Attribute: s.hierarchicalClassAttr, + Value: &policy.Value{Fqn: classPublicFQN}, + }, + deptFinanceFQN: { + Attribute: s.anyOfDepartmentAttr, + Value: &policy.Value{Fqn: deptFinanceFQN}, + }, + deptMarketingFQN: { + Attribute: s.anyOfDepartmentAttr, + Value: &policy.Value{Fqn: deptMarketingFQN}, + }, + deptLegalFQN: { + Attribute: s.anyOfDepartmentAttr, + Value: &policy.Value{Fqn: deptLegalFQN}, + }, + projectAvengersFQN: { + Attribute: s.allOfProjectAttr, + Value: &policy.Value{Fqn: projectAvengersFQN}, + }, + projectJusticeLeagueFQN: { + Attribute: s.allOfProjectAttr, + Value: &policy.Value{Fqn: projectJusticeLeagueFQN}, + }, + projectXmenFQN: { + Attribute: s.allOfProjectAttr, + Value: &policy.Value{Fqn: projectXmenFQN}, + }, + projectFantasicFourFQN: { + Attribute: s.allOfProjectAttr, + Value: &policy.Value{Fqn: projectFantasicFourFQN}, + }, + } +} + +func TestEvaluateSuite(t *testing.T) { + suite.Run(t, new(EvaluateTestSuite)) +} + +// Test cases for allOfRule +func (s *EvaluateTestSuite) TestAllOfRule() { + tests := []struct { + name string + resourceValueFQNs []string + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + expectedFailures int + }{ + { + name: "all entitlements present", + resourceValueFQNs: []string{ + projectAvengersFQN, + projectJusticeLeagueFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: []*policy.Action{actionRead}, + projectJusticeLeagueFQN: []*policy.Action{actionRead}, + }, + expectedFailures: 0, + }, + { + name: "one entitlement (action) missing", + resourceValueFQNs: []string{ + projectJusticeLeagueFQN, + projectFantasicFourFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectJusticeLeagueFQN: []*policy.Action{actionRead}, + projectFantasicFourFQN: []*policy.Action{actionCreate}, // Wrong action + }, + expectedFailures: 1, + }, + { + name: "all entitlement (actions) missing", + resourceValueFQNs: []string{ + projectXmenFQN, + projectJusticeLeagueFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectXmenFQN: []*policy.Action{actionCreate}, // Wrong action + projectJusticeLeagueFQN: []*policy.Action{actionCreate}, // Wrong action + }, + expectedFailures: 2, + }, + { + name: "missing FQN in entitlements", + resourceValueFQNs: []string{ + projectAvengersFQN, + projectFantasicFourFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: []*policy.Action{actionRead}, + // Missing classRestrictedFQN entirely + }, + expectedFailures: 1, + }, + { + name: "multiple entitlements with mixed actions", + resourceValueFQNs: []string{ + projectAvengersFQN, + projectJusticeLeagueFQN, + projectXmenFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: []*policy.Action{actionRead, actionCreate}, + projectJusticeLeagueFQN: []*policy.Action{actionRead}, + projectXmenFQN: []*policy.Action{actionRead, actionCreate}, + }, + expectedFailures: 0, // All resources have read action entitled + }, + { + name: "empty resource list", + resourceValueFQNs: []string{}, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: []*policy.Action{actionRead}, + projectJusticeLeagueFQN: []*policy.Action{actionRead}, + }, + expectedFailures: 0, // No resources to check, should pass + }, + { + name: "empty entitlements", + resourceValueFQNs: []string{ + projectAvengersFQN, + projectJusticeLeagueFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + expectedFailures: 2, // All resources should fail + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + failures := allOfRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs) + + s.Len(failures, tc.expectedFailures) + + // If expected failures, verify they are for the correct FQNs + if tc.expectedFailures == 0 { + return + } + failedFQNs := make(map[string]bool) + for _, failure := range failures { + failedFQNs[failure.AttributeValueFQN] = true + s.Equal(s.action.GetName(), failure.ActionName) + } + + // Verify each failure is for an actual resource value FQN + for _, fqn := range tc.resourceValueFQNs { + if entitlementActions, exists := tc.entitlements[fqn]; !exists { + s.True(failedFQNs[fqn], "FQN %s should be in failures", fqn) + } else { + hasReadAction := false + for _, entAction := range entitlementActions { + if strings.EqualFold(entAction.GetName(), s.action.GetName()) { + hasReadAction = true + break + } + } + if !hasReadAction { + s.True(failedFQNs[fqn], "FQN %s should be in failures", fqn) + } + } + } + }) + } +} + +// Test cases for anyOfRule +func (s *EvaluateTestSuite) TestAnyOfRule() { + tests := []struct { + name string + resourceValueFQNs []string + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + expectedFailCount int + }{ + { + name: "all entitlements present", + resourceValueFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionRead}, + deptMarketingFQN: []*policy.Action{actionRead}, + }, + expectedFailCount: 0, + }, + { + name: "one entitlement present", + resourceValueFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionRead}, + deptMarketingFQN: []*policy.Action{actionCreate}, // Wrong action + }, + expectedFailCount: 0, // Still passes because at least one is entitled + }, + { + name: "no entitlements present", + resourceValueFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionCreate}, // Wrong action + deptMarketingFQN: []*policy.Action{actionCreate}, // Wrong action + }, + expectedFailCount: 2, // Both failed so rule fails + }, + { + name: "no matching FQNs", + resourceValueFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptLegalFQN: []*policy.Action{actionRead}, // Wrong FQN + }, + expectedFailCount: 2, // Both failed so rule fails + }, + { + name: "entitlement with multiple actions", + resourceValueFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionCreate, actionRead}, // Has multiple actions including the required one + }, + expectedFailCount: 0, // Should pass as at least one FQN has the required action + }, + { + name: "single resource with required entitlement", + resourceValueFQNs: []string{ + deptFinanceFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionRead}, + }, + expectedFailCount: 0, + }, + { + name: "empty resource list", + resourceValueFQNs: []string{}, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionRead}, + deptMarketingFQN: []*policy.Action{actionRead}, + }, + expectedFailCount: 0, // Should pass as there are no resources to check + }, + { + name: "empty entitlements", + resourceValueFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + expectedFailCount: 2, // Should fail as there are no entitlements + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + // Execute + failures := anyOfRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs) + + // Assert + if tc.expectedFailCount == 0 { + s.Nil(failures, "Expected no failures but got: %v", failures) + return + } + + s.Len(failures, tc.expectedFailCount) + + // Verify each failure is for an actual resource value FQN + failedFQNs := make(map[string]bool) + for _, failure := range failures { + failedFQNs[failure.AttributeValueFQN] = true + s.Equal(s.action.GetName(), failure.ActionName) + } + + for _, fqn := range tc.resourceValueFQNs { + // If this FQN has no entitlements or doesn't have the right action, it should be in failures + if entitlementActions, exists := tc.entitlements[fqn]; exists { + hasRightEntitlement := false + for _, entAction := range entitlementActions { + if strings.EqualFold(entAction.GetName(), s.action.GetName()) { + hasRightEntitlement = true + break + } + } + if hasRightEntitlement { + continue + } + } + s.True(failedFQNs[fqn], "FQN %s should be in failures", fqn) + } + }) + } +} + +// Test cases for hierarchyRule +func (s *EvaluateTestSuite) TestHierarchyRule() { + tests := []struct { + name string + resourceValueFQNs []string + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + expectedFailures bool + }{ + { + name: "entitled to highest value", + resourceValueFQNs: []string{ + classSecretFQN, + classConfidentialFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionRead}, // Entitled to highest value + }, + expectedFailures: false, + }, + { + name: "entitled to higher value", + resourceValueFQNs: []string{ + classRestrictedFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classTopSecretFQN: []*policy.Action{actionRead}, // Entitled to highest value + }, + expectedFailures: false, + }, + { + name: "entitled to higher value 2", + resourceValueFQNs: []string{ + classRestrictedFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionRead}, // Entitled to higher value + }, + expectedFailures: false, + }, + { + name: "multi higher entitlements", + resourceValueFQNs: []string{ + classPublicFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionRead}, // higher + classConfidentialFQN: []*policy.Action{actionRead}, // higher + }, + expectedFailures: false, + }, + { + name: "higher and lower entitlements", + resourceValueFQNs: []string{ + classRestrictedFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classPublicFQN: []*policy.Action{actionRead}, // lower + classSecretFQN: []*policy.Action{actionRead}, // higher + }, + expectedFailures: false, + }, + { + name: "entitled to lower value but not highest", + resourceValueFQNs: []string{ + classSecretFQN, + classConfidentialFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, // Only entitled to lower value + }, + expectedFailures: true, + }, + { + name: "entitled to wrong action on highest value", + resourceValueFQNs: []string{ + classSecretFQN, + classConfidentialFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionCreate}, // Wrong action + }, + expectedFailures: true, + }, + { + name: "highest value from multiple resources", + resourceValueFQNs: []string{ + classConfidentialFQN, + classTopSecretFQN, // This is highest + classRestrictedFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classTopSecretFQN: []*policy.Action{actionRead}, + }, + expectedFailures: false, + }, + { + name: "entitled to much higher value in hierarchy than requested", + resourceValueFQNs: []string{ + classPublicFQN, // Lowest in hierarchy (index 4) + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classTopSecretFQN: []*policy.Action{actionRead}, // Highest in hierarchy (index 0) + }, + expectedFailures: false, // Should pass with the fix + }, + { + name: "entitled to multiple values higher in hierarchy than requested", + resourceValueFQNs: []string{ + classRestrictedFQN, // Lower in hierarchy (index 3) + classPublicFQN, // Lowest in hierarchy (index 4) + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + // No entitlement for exact matches + classTopSecretFQN: []*policy.Action{actionRead}, // Much higher in hierarchy (index 0) + classSecretFQN: []*policy.Action{actionRead}, // Higher in hierarchy (index 1) + }, + expectedFailures: false, // Should pass with the fix + }, + { + name: "entitled to value higher than highest requested but wrong action", + resourceValueFQNs: []string{ + classConfidentialFQN, // Middle in hierarchy (index 2) + classRestrictedFQN, // Lower in hierarchy (index 3) + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionCreate}, // Higher but wrong action + classTopSecretFQN: []*policy.Action{actionCreate}, // Highest but wrong action + }, + expectedFailures: true, // Should fail due to wrong action + }, + { + name: "empty resource list", + resourceValueFQNs: []string{}, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionRead}, + }, + expectedFailures: false, // No resources to check, should pass + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + // Execute + failures := hierarchyRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, s.hierarchicalClassAttr) + + // Assert + if tc.expectedFailures { + s.NotEmpty(failures, "Expected failures but got none") + } else { + s.Empty(failures, "Expected no failures but got: %v", failures) + } + }) + } +} + +// Test cases for evaluateDefinition +func (s *EvaluateTestSuite) TestEvaluateDefinition() { + tests := []struct { + name string + definition *policy.Attribute + resourceValues []string + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + expectPass bool + expectError bool + }{ + { + name: "all-of rule passing", + definition: s.allOfProjectAttr, + resourceValues: []string{ + classConfidentialFQN, + classRestrictedFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, + classRestrictedFQN: []*policy.Action{actionRead}, + }, + expectPass: true, + expectError: false, + }, + { + name: "any-of rule passing", + definition: s.anyOfDepartmentAttr, + resourceValues: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: []*policy.Action{actionRead}, + }, + expectPass: true, + expectError: false, + }, + { + name: "hierarchy rule passing", + definition: s.hierarchicalClassAttr, + resourceValues: []string{ + classSecretFQN, + classConfidentialFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classSecretFQN: []*policy.Action{actionRead}, + }, + expectPass: true, + expectError: false, + }, + { + name: "unspecified rule type", + definition: &policy.Attribute{ + Fqn: classificationFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED, + Values: []*policy.Value{ + {Fqn: classConfidentialFQN}, + }, + }, + resourceValues: []string{classConfidentialFQN}, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + expectPass: false, + expectError: true, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValues, tc.definition) + + if tc.expectError { + s.Error(err) + } else { + s.Require().NoError(err) + s.NotNil(result) + s.Equal(tc.expectPass, result.Passed) + } + }) + } +} + +// Test cases for evaluateResourceAttributeValues +func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { + tests := []struct { + name string + resourceAttrs *authz.Resource_AttributeValues + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + expectAccessible bool + expectError bool + }{ + { + name: "all rules passing", + resourceAttrs: &authz.Resource_AttributeValues{ + Fqns: []string{ + classConfidentialFQN, + deptFinanceFQN, + }, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, + deptFinanceFQN: []*policy.Action{actionRead}, + }, + expectAccessible: true, + expectError: false, + }, + { + name: "all rules passing - non lower-cased FQNs", + resourceAttrs: &authz.Resource_AttributeValues{ + Fqns: []string{ + strings.ToUpper(classConfidentialFQN), + strings.ToUpper(deptFinanceFQN), + }, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, + deptFinanceFQN: []*policy.Action{actionRead}, + }, + expectAccessible: true, + expectError: false, + }, + { + name: "one rule failing", + resourceAttrs: &authz.Resource_AttributeValues{ + Fqns: []string{ + classConfidentialFQN, + deptFinanceFQN, + }, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, + deptFinanceFQN: []*policy.Action{actionCreate}, // Wrong action + }, + expectAccessible: false, + expectError: false, + }, + { + name: "unknown attribute value FQN", + resourceAttrs: &authz.Resource_AttributeValues{ + Fqns: []string{ + classConfidentialFQN, + "https://namespace.com/attr/department/value/unknown", // This FQN doesn't exist in accessibleAttributeValues + }, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, + }, + expectAccessible: false, + expectError: true, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + resourceDecision, err := evaluateResourceAttributeValues( + s.T().Context(), + s.logger, + tc.resourceAttrs, + "test-resource-id", + s.action, + tc.entitlements, + s.accessibleAttrValues, + ) + + if tc.expectError { + s.Require().Error(err) + } else { + s.Require().NoError(err) + s.NotNil(resourceDecision) + s.Equal(tc.expectAccessible, resourceDecision.Passed) + + // Check results array has the correct length based on grouping by definition + definitions := make(map[string]bool) + for _, fqn := range tc.resourceAttrs.GetFqns() { + if attrAndValue, ok := s.accessibleAttrValues[fqn]; ok { + definitions[attrAndValue.GetAttribute().GetFqn()] = true + } + } + s.Len(resourceDecision.DataRuleResults, len(definitions)) + } + }) + } +} + +// Test cases for getResourceDecision +func (s *EvaluateTestSuite) TestGetResourceDecision() { + tests := []struct { + name string + resource *authz.Resource + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + expectError bool + }{ + { + name: "attribute values resource", + resource: &authz.Resource{ + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{classConfidentialFQN}, + }, + }, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + classConfidentialFQN: []*policy.Action{actionRead}, + }, + expectError: false, + }, + { + name: "invalid nil resource", + resource: nil, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + expectError: true, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + decision, err := getResourceDecision( + s.T().Context(), + s.logger, + s.accessibleAttrValues, + tc.entitlements, + s.action, + tc.resource, + ) + + if tc.expectError { + s.Error(err) + } else { + s.Require().NoError(err) + s.NotNil(decision) + } + }) + } +} diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go new file mode 100644 index 0000000000..16e82087b1 --- /dev/null +++ b/service/internal/access/v2/helpers.go @@ -0,0 +1,178 @@ +package access + +import ( + "context" + "fmt" + "log/slog" + + "github.com/opentdf/platform/lib/identifier" + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/logger" +) + +// getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions canmap +func getDefinition(valueFQN string, allDefinitionsByDefFQN map[string]*policy.Attribute) (*policy.Attribute, error) { + parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](valueFQN) + if err != nil { + return nil, fmt.Errorf("failed to parse attribute value FQN [%s]: %w", valueFQN, err) + } + def := &identifier.FullyQualifiedAttribute{ + Namespace: parsed.Namespace, + Name: parsed.Name, + } + + definition, ok := allDefinitionsByDefFQN[def.FQN()] + if !ok { + return nil, fmt.Errorf("definition not found: %w", err) + } + return definition, nil +} + +// getFilteredEntitleableAttributes filters the entitleable attributes to only those that are in the optional matched subject mappings +func getFilteredEntitleableAttributes( + matchedSubjectMappings []*policy.SubjectMapping, + allEntitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, +) (map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, error) { + filtered := make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) + + for _, sm := range matchedSubjectMappings { + mappedValue := sm.GetAttributeValue() + mappedValueFQN := mappedValue.GetFqn() + + if _, ok := allEntitleableAttributesByValueFQN[mappedValueFQN]; !ok { + return nil, fmt.Errorf("invalid attribute value FQN in optional matched subject mappings: %w", ErrInvalidSubjectMapping) + } + // Take subject mapping's attribute value and its definition from memory + attributeAndValue, ok := allEntitleableAttributesByValueFQN[mappedValueFQN] + if !ok { + return nil, fmt.Errorf("attribute value not found in memory: %s", mappedValueFQN) + } + parentDefinition := attributeAndValue.GetAttribute() + + // Create a copy of the value with the subject mapping + valueWithMapping := &policy.Value{ + Fqn: mappedValue.GetFqn(), + Value: mappedValue.GetValue(), + SubjectMappings: []*policy.SubjectMapping{sm}, + } + + mapped := &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Value: valueWithMapping, + Attribute: parentDefinition, + } + + // If this value already exists in the filtered map, append the subject mapping + if existing, exists := filtered[mappedValueFQN]; exists { + existing.Value.SubjectMappings = append(existing.Value.SubjectMappings, sm) + } else { + filtered[mappedValueFQN] = mapped + } + } + + return filtered, nil +} + +// populateLowerValuesIfHierarchy populates the lower values if the attribute is of type hierarchy +func populateLowerValuesIfHierarchy( + valueFQN string, + entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + entitledActions *authz.EntityEntitlements_ActionsList, + entitledActionsPerAttributeValueFqn map[string]*authz.EntityEntitlements_ActionsList, +) error { + attributeAndValue, ok := entitleableAttributes[valueFQN] + if !ok { + return fmt.Errorf("attribute value not found in memory: %s", valueFQN) + } + definition := attributeAndValue.GetAttribute() + if definition == nil { + return fmt.Errorf("attribute is nil: %w", ErrInvalidAttributeDefinition) + } + if definition.GetRule() != policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + return nil + } + + lower := false + for _, value := range definition.GetValues() { + if lower { + alreadyEntitledActions, exists := entitledActionsPerAttributeValueFqn[value.GetFqn()] + if !exists { + entitledActionsPerAttributeValueFqn[value.GetFqn()] = entitledActions + } else { + // Ensure the actions are unique + mergedActions := mergeDeduplicatedActions(entitledActions.GetActions(), alreadyEntitledActions.GetActions()) + + merged := &authz.EntityEntitlements_ActionsList{ + Actions: mergedActions, + } + + entitledActionsPerAttributeValueFqn[value.GetFqn()] = merged + } + } + if value.GetFqn() == valueFQN { + lower = true + } + } + + return nil +} + +// populateHigherValuesIfHierarchy sets the higher values if the attribute is of type hierarchy to +// the decisionable attributes map +func populateHigherValuesIfHierarchy( + ctx context.Context, + l *logger.Logger, + valueFQN string, + definition *policy.Attribute, + allEntitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + decisionableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, +) error { + if definition == nil { + return fmt.Errorf("attribute is nil: %w", ErrInvalidAttributeDefinition) + } + if definition.GetRule() != policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + return nil + } + + for _, value := range definition.GetValues() { + if value.GetFqn() == valueFQN { + break + } + // Pull the value from the lookup store holding subject mappings + fullValue, ok := allEntitleableAttributesByValueFQN[value.GetFqn()] + if !ok { + l.WarnContext(ctx, "value FQN of hierarchy attribute not found available for lookup, may not have had subject mappings associated or provided", slog.String("value FQN", value.GetFqn())) + continue + } + decisionableAttributes[value.GetFqn()] = &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Value: fullValue.GetValue(), + Attribute: definition, + } + } + + return nil +} + +// Deduplicate and merge two lists of actions +func mergeDeduplicatedActions(existingActions []*policy.Action, actionsToMerge []*policy.Action) []*policy.Action { + actionMap := make(map[string]*policy.Action) + + // Add existing actions to the map + for _, action := range existingActions { + actionMap[action.GetName()] = action + } + + // Add or override with actions to merge + for _, action := range actionsToMerge { + actionMap[action.GetName()] = action + } + + // Convert map back to slice + merged := make([]*policy.Action, 0, len(actionMap)) + for _, action := range actionMap { + merged = append(merged, action) + } + + return merged +} diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go new file mode 100644 index 0000000000..275cba1889 --- /dev/null +++ b/service/internal/access/v2/helpers_test.go @@ -0,0 +1,570 @@ +package access + +import ( + "testing" + + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/policy/actions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Updated assertions to include better validation of the retrieved definition +func TestGetDefinition(t *testing.T) { + validFQN := "https://example.org/attr/classification/value/public" + invalidFQN := "invalid-fqn" + + validDefinition := &policy.Attribute{ + Fqn: "https://example.org/attr/classification", + } + + definitions := map[string]*policy.Attribute{ + "https://example.org/attr/classification": validDefinition, + } + + tests := []struct { + name string + valueFQN string + definitions map[string]*policy.Attribute + wantErr bool + }{ + { + name: "Valid FQN", + valueFQN: validFQN, + definitions: definitions, + wantErr: false, + }, + { + name: "Valid FQN not found", + valueFQN: "https://example.org/attr/unknown/value/unknown", + definitions: definitions, + wantErr: true, + }, + { + name: "Invalid FQN", + valueFQN: invalidFQN, + definitions: definitions, + wantErr: true, + }, + { + name: "Empty FQN", + valueFQN: "", + definitions: definitions, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + def, err := getDefinition(tt.valueFQN, tt.definitions) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, validDefinition, def, "Expected definition to match") + } + }) + } +} + +func TestGetFilteredEntitleableAttributes(t *testing.T) { + // Set up multiple attributes and values to thoroughly test filtering + classificationFQN := "https://example.org/attr/classification" + publicFQN := "https://example.org/attr/classification/value/public" + confidentialFQN := "https://example.org/attr/classification/value/confidential" + secretFQN := "https://example.org/attr/classification/value/secret" + + departmentFQN := "https://example.org/attr/department" + hrFQN := "https://example.org/attr/department/value/hr" + financeFQN := "https://example.org/attr/department/value/finance" + itFQN := "https://example.org/attr/department/value/it" + + invalidFQN := "invalid-fqn" + + // Create attribute definitions + classificationAttr := &policy.Attribute{ + Fqn: classificationFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + } + + departmentAttr := &policy.Attribute{ + Fqn: departmentFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + } + + // Create attribute values with mappings to their respective definitions + publicValue := &policy.Value{Fqn: publicFQN} + confidentialValue := &policy.Value{Fqn: confidentialFQN} + secretValue := &policy.Value{Fqn: secretFQN} + + hrValue := &policy.Value{Fqn: hrFQN} + financeValue := &policy.Value{Fqn: financeFQN} + itValue := &policy.Value{Fqn: itFQN} + + // Create subject mappings for some of the values + publicMapping := &policy.SubjectMapping{ + AttributeValue: publicValue, + } + + confidentialMapping := &policy.SubjectMapping{ + AttributeValue: confidentialValue, + } + + hrMapping := &policy.SubjectMapping{ + AttributeValue: hrValue, + } + + invalidMapping := &policy.SubjectMapping{ + AttributeValue: &policy.Value{Fqn: invalidFQN}, + } + + // Create the complete map of all entitleable attributes + allEntitleableAttributes := map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + publicFQN: { + Value: publicValue, + Attribute: classificationAttr, + }, + confidentialFQN: { + Value: confidentialValue, + Attribute: classificationAttr, + }, + secretFQN: { + Value: secretValue, + Attribute: classificationAttr, + }, + hrFQN: { + Value: hrValue, + Attribute: departmentAttr, + }, + financeFQN: { + Value: financeValue, + Attribute: departmentAttr, + }, + itFQN: { + Value: itValue, + Attribute: departmentAttr, + }, + } + + tests := []struct { + name string + matchedSubjectMappings []*policy.SubjectMapping + allEntitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + expectedFilteredFQNs []string + unexpectedFilteredFQNs []string + wantErr bool + }{ + { + name: "Filter to single value", + matchedSubjectMappings: []*policy.SubjectMapping{publicMapping}, + allEntitleableAttributes: allEntitleableAttributes, + expectedFilteredFQNs: []string{publicFQN}, + unexpectedFilteredFQNs: []string{confidentialFQN, secretFQN, hrFQN, financeFQN, itFQN}, + wantErr: false, + }, + { + name: "Filter to multiple values from same attribute", + matchedSubjectMappings: []*policy.SubjectMapping{publicMapping, confidentialMapping}, + allEntitleableAttributes: allEntitleableAttributes, + expectedFilteredFQNs: []string{publicFQN, confidentialFQN}, + unexpectedFilteredFQNs: []string{secretFQN, hrFQN, financeFQN, itFQN}, + wantErr: false, + }, + { + name: "Filter to values from different attributes", + matchedSubjectMappings: []*policy.SubjectMapping{publicMapping, hrMapping}, + allEntitleableAttributes: allEntitleableAttributes, + expectedFilteredFQNs: []string{publicFQN, hrFQN}, + unexpectedFilteredFQNs: []string{confidentialFQN, secretFQN, financeFQN, itFQN}, + wantErr: false, + }, + { + name: "Empty subject mappings result in empty filtered map", + matchedSubjectMappings: []*policy.SubjectMapping{}, + allEntitleableAttributes: allEntitleableAttributes, + expectedFilteredFQNs: []string{}, + unexpectedFilteredFQNs: []string{publicFQN, confidentialFQN, secretFQN, hrFQN, financeFQN, itFQN}, + wantErr: false, + }, + { + name: "Invalid FQN in subject mapping causes error", + matchedSubjectMappings: []*policy.SubjectMapping{publicMapping, invalidMapping}, + allEntitleableAttributes: allEntitleableAttributes, + expectedFilteredFQNs: []string{}, + unexpectedFilteredFQNs: []string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filtered, err := getFilteredEntitleableAttributes(tt.matchedSubjectMappings, tt.allEntitleableAttributes) + + // Check error handling + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // Verify size matches expected number of filtered elements + assert.Len(t, filtered, len(tt.expectedFilteredFQNs), + "Expected filtered map to have %d elements, got %d", + len(tt.expectedFilteredFQNs), len(filtered)) + + // Verify expected FQNs are present + for _, expectedFQN := range tt.expectedFilteredFQNs { + attributeAndValue, exists := filtered[expectedFQN] + assert.True(t, exists, "Expected filtered results to contain FQN: %s", expectedFQN) + + // Verify attribute definitions are preserved from the original map + originalAttributeAndValue := tt.allEntitleableAttributes[expectedFQN] + assert.Equal(t, originalAttributeAndValue.GetAttribute(), attributeAndValue.GetAttribute(), + "Expected attribute definition to be preserved for FQN: %s", expectedFQN) + + // Verify value FQN is correct + assert.Equal(t, expectedFQN, attributeAndValue.GetValue().GetFqn(), + "Expected value FQN to match for FQN: %s", expectedFQN) + } + + // Verify unexpected FQNs are not present + for _, unexpectedFQN := range tt.unexpectedFilteredFQNs { + _, exists := filtered[unexpectedFQN] + assert.False(t, exists, "Unexpected FQN found in filtered results: %s", unexpectedFQN) + } + }) + } +} + +func TestPopulateLowerValuesIfHierarchy(t *testing.T) { + values := []*policy.Value{ + {Fqn: "https://example.org/attr/classification/value/secret"}, + {Fqn: "https://example.org/attr/classification/value/restricted"}, + {Fqn: "https://example.org/attr/classification/value/confidential"}, + {Fqn: "https://example.org/attr/classification/value/public"}, + } + hierarchyAttributeAndValue := &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Attribute: &policy.Attribute{ + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: values, + }, + } + + entitledActions := &authz.EntityEntitlements_ActionsList{ + Actions: []*policy.Action{ + {Name: actions.ActionNameRead}, + {Name: actions.ActionNameCreate}, + }, + } + + tests := []struct { + name string + valueFQN string + attributeAndValue *attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + entitledActions *authz.EntityEntitlements_ActionsList + actionsPerAttributeValueFqn map[string]*authz.EntityEntitlements_ActionsList + wantErr error + expectedMapKeyFQNs []string + }{ + { + name: "Top level hierarchy value", + valueFQN: values[0].GetFqn(), + attributeAndValue: hierarchyAttributeAndValue, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: []string{ + values[1].GetFqn(), + values[2].GetFqn(), + values[3].GetFqn(), + }, + }, + { + name: "mid level hierarchy value", + valueFQN: values[2].GetFqn(), + attributeAndValue: hierarchyAttributeAndValue, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: []string{ + values[3].GetFqn(), + }, + }, + { + name: "lowest level hierarchy value", + valueFQN: values[3].GetFqn(), + attributeAndValue: hierarchyAttributeAndValue, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: []string{}, + }, + { + name: "Missing attribute rule", + valueFQN: values[0].GetFqn(), + attributeAndValue: &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Attribute: &policy.Attribute{ + Values: values, + }, + }, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: nil, + }, + { + name: "Unspecified attribute rule", + valueFQN: values[0].GetFqn(), + attributeAndValue: &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Attribute: &policy.Attribute{ + Values: values, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED, + }, + }, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: nil, + }, + { + name: "ANY_OF attribute rule", + valueFQN: values[0].GetFqn(), + attributeAndValue: &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Attribute: &policy.Attribute{ + Values: values, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + }, + }, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: nil, + }, + { + name: "ALL_OF attribute rule", + valueFQN: values[0].GetFqn(), + attributeAndValue: &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Attribute: &policy.Attribute{ + Values: values, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + }, + }, + entitledActions: entitledActions, + actionsPerAttributeValueFqn: make(map[string]*authz.EntityEntitlements_ActionsList), + wantErr: nil, + expectedMapKeyFQNs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entitleableAttributes := map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + tt.valueFQN: tt.attributeAndValue, + } + err := populateLowerValuesIfHierarchy(tt.valueFQN, entitleableAttributes, tt.entitledActions, tt.actionsPerAttributeValueFqn) + + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Len(t, tt.expectedMapKeyFQNs, len(tt.actionsPerAttributeValueFqn), "Expected map to have %d keys, got %d", len(tt.expectedMapKeyFQNs), len(tt.actionsPerAttributeValueFqn)) + for _, key := range tt.expectedMapKeyFQNs { + assert.Contains(t, tt.actionsPerAttributeValueFqn, key, "Expected map to contain key %s", key) + assert.Equal(t, tt.entitledActions, tt.actionsPerAttributeValueFqn[key], "Expected map value for key %s to match", key) + assert.Len(t, tt.actionsPerAttributeValueFqn[key].GetActions(), len(tt.entitledActions.GetActions()), "Expected map value for key %s to match", key) + } + } + }) + } +} + +func TestPopulateHigherValuesIfHierarchy(t *testing.T) { + exampleSecretFQN := "https://example.org/attr/classification/value/secret" + exampleRestrictedFQN := "https://example.org/attr/classification/value/restricted" + exampleConfidentialFQN := "https://example.org/attr/classification/value/confidential" + examplePublicFQN := "https://example.org/attr/classification/value/public" + + valueSecret := &policy.Value{ + Fqn: exampleSecretFQN, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "secret", []*policy.Action{actionRead}, ".test", []string{"value"})}, + } + valueRestricted := &policy.Value{ + Fqn: exampleRestrictedFQN, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "restricted", []*policy.Action{actionRead}, ".test", []string{"somethingelse"})}, + } + valueConf := &policy.Value{ + Fqn: exampleConfidentialFQN, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleConfidentialFQN, "confidential", []*policy.Action{actionRead}, ".hello", []string{"world"})}, + } + valuePublic := &policy.Value{ + Fqn: examplePublicFQN, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(examplePublicFQN, "public", []*policy.Action{actionRead}, ".goodnight", []string{"moon"})}, + } + + values := []*policy.Value{valueSecret, valueRestricted, valueConf, valuePublic} + + hierarchyAttribute := &policy.Attribute{ + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: values, + } + anyOfAttribute := &policy.Attribute{ + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{}, + } + allOfAttribute := &policy.Attribute{ + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{}, + } + + allValueFQNsToAttributeValues := map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + exampleSecretFQN: { + Value: valueSecret, + Attribute: hierarchyAttribute, + }, + exampleRestrictedFQN: { + Value: valueRestricted, + Attribute: hierarchyAttribute, + }, + exampleConfidentialFQN: { + Value: valueConf, + Attribute: hierarchyAttribute, + }, + examplePublicFQN: { + Value: valuePublic, + Attribute: hierarchyAttribute, + }, + } + + tests := []struct { + name string + valueFQN string + definition *policy.Attribute + initialAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + wantErr error + expectedMapAdditions []string + }{ + { + name: "Top level hierarchy value", + valueFQN: exampleSecretFQN, + definition: hierarchyAttribute, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: nil, + expectedMapAdditions: []string{}, // No higher values should be added for top level + }, + { + name: "Second level hierarchy value", + valueFQN: exampleRestrictedFQN, + definition: hierarchyAttribute, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: nil, + expectedMapAdditions: []string{exampleSecretFQN}, // Should add the top level + }, + { + name: "Third level hierarchy value", + valueFQN: exampleConfidentialFQN, + definition: hierarchyAttribute, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: nil, + expectedMapAdditions: []string{exampleRestrictedFQN, exampleSecretFQN}, // Should add the top two levels + }, + { + name: "Bottom level hierarchy value", + valueFQN: examplePublicFQN, + definition: hierarchyAttribute, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: nil, + expectedMapAdditions: []string{exampleConfidentialFQN, exampleSecretFQN, exampleRestrictedFQN}, // Should add all higher levels + }, + { + name: "Non-hierarchy attribute", + valueFQN: "irrelevant-to-this-test", + definition: anyOfAttribute, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: nil, + expectedMapAdditions: []string{}, // No additions for non-hierarchy attributes + }, + { + name: "All-of attribute", + valueFQN: "irrelevant-to-this-test", + definition: allOfAttribute, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: nil, + expectedMapAdditions: []string{}, // No additions for non-hierarchy attributes + }, + { + name: "Nil attribute", + valueFQN: exampleRestrictedFQN, + definition: nil, + initialAttributes: make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + wantErr: ErrInvalidAttributeDefinition, + expectedMapAdditions: []string{}, // Error expected, no additions + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decisionableAttributes := tt.initialAttributes + + err := populateHigherValuesIfHierarchy(t.Context(), logger.CreateTestLogger(), tt.valueFQN, tt.definition, allValueFQNsToAttributeValues, decisionableAttributes) + + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + + // Check for expected additions to the map + for _, expectedAddition := range tt.expectedMapAdditions { + attributeAndValue, exists := decisionableAttributes[expectedAddition] + assert.True(t, exists, "Expected map to contain key %s", expectedAddition) + assert.Equal(t, tt.definition, attributeAndValue.GetAttribute(), "Expected attribute to match definition") + assert.Equal(t, expectedAddition, attributeAndValue.GetValue().GetFqn(), "Expected value FQN to match") + assert.NotEmpty(t, attributeAndValue.GetValue().GetSubjectMappings(), "Bubbled up higher hierarchy values should contain subject mappings to check entitlement") + } + + // Verify only the expected keys were added + assert.Len(t, decisionableAttributes, len(tt.expectedMapAdditions), "Expected %d additions to map, got %d", len(tt.expectedMapAdditions), len(decisionableAttributes)) + }) + } + + decisionableAttributes := map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{} + + // Populate up from second highest + err := populateHigherValuesIfHierarchy(t.Context(), logger.CreateTestLogger(), exampleRestrictedFQN, hierarchyAttribute, allValueFQNsToAttributeValues, decisionableAttributes) + require.NoError(t, err) + assert.NotNil(t, decisionableAttributes) + assert.Len(t, decisionableAttributes, 1) + + // Secret should have been added, as it's higher than restriected + decisionableSecret := decisionableAttributes[exampleSecretFQN] + assert.NotNil(t, decisionableSecret) + assert.NotEmpty(t, decisionableSecret.GetValue().GetSubjectMappings()) + + // Call it with lowest + err = populateHigherValuesIfHierarchy(t.Context(), logger.CreateTestLogger(), examplePublicFQN, hierarchyAttribute, allValueFQNsToAttributeValues, decisionableAttributes) + require.NoError(t, err) + assert.NotNil(t, decisionableAttributes) + + // Every value above public should be present + assert.Len(t, decisionableAttributes, 3) + found := map[string]bool{ + exampleSecretFQN: false, + exampleRestrictedFQN: false, + exampleConfidentialFQN: false, + } + for fqn, attrAndVal := range decisionableAttributes { + _, exists := found[fqn] + assert.True(t, exists) + found[fqn] = true + assert.NotEmpty(t, attrAndVal.GetValue().GetSubjectMappings()) + } + for _, state := range found { + assert.True(t, state) + } +} diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go new file mode 100644 index 0000000000..3b35af0a21 --- /dev/null +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -0,0 +1,333 @@ +package access + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/opentdf/platform/lib/flattening" + authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/common" + "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" + + "github.com/opentdf/platform/service/logger" +) + +type JustInTimePDP struct { + logger *logger.Logger + sdk *otdfSDK.SDK + // embedded PDP + pdp *PolicyDecisionPoint +} + +// JustInTimePDP creates a new Policy Decision Point instance with no in-memory policy and a remote connection +// via authenticated SDK, then fetches all Attributes and Subject Mappings from the policy services. +func NewJustInTimePDP( + ctx context.Context, + l *logger.Logger, + sdk *otdfSDK.SDK, +) (*JustInTimePDP, error) { + var err error + + if sdk == nil { + l.ErrorContext(ctx, "invalid arguments", slog.String("error", ErrMissingRequiredSDK.Error())) + return nil, ErrMissingRequiredSDK + } + if l == nil { + l, err = logger.NewLogger(defaultFallbackLoggerConfig) + if err != nil { + return nil, fmt.Errorf("failed to initialize new PDP logger and none was provided: %w", err) + } + } + + p := &JustInTimePDP{ + sdk: sdk, + logger: l, + } + + allAttributes, err := p.fetchAllDefinitions(ctx) + if err != nil { + l.ErrorContext(ctx, "failed to fetch all attribute definitions", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to fetch all attribute definitions: %w", err) + } + allSubjectMappings, err := p.fetchAllSubjectMappings(ctx) + if err != nil { + l.ErrorContext(ctx, "failed to fetch all subject mappings", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to fetch all subject mappings: %w", err) + } + pdp, err := NewPolicyDecisionPoint(ctx, l, allAttributes, allSubjectMappings) + if err != nil { + l.ErrorContext(ctx, "failed to create new policy decision point", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to create new policy decision point: %w", err) + } + p.pdp = pdp + return p, nil +} + +// GetDecision retrieves the decision for the provided entity chain, action, and resources. +// 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. +func (p *JustInTimePDP) GetDecision( + ctx context.Context, + entityIdentifier *authzV2.EntityIdentifier, + action *policy.Action, + resources []*authzV2.Resource, +) ([]*Decision, bool, error) { + var ( + entityRepresentations []*entityresolutionV2.EntityRepresentation + err error + skipEnvironmentEntities = true + ) + + switch entityIdentifier.GetIdentifier().(type) { + case *authzV2.EntityIdentifier_EntityChain: + entityRepresentations, err = p.resolveEntitiesFromEntityChain(ctx, entityIdentifier.GetEntityChain(), skipEnvironmentEntities) + + case *authzV2.EntityIdentifier_Token: + p.logger.DebugContext(ctx, "getting decision - resolving token") + entityRepresentations, err = p.resolveEntitiesFromToken(ctx, entityIdentifier.GetToken(), skipEnvironmentEntities) + + case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: + p.logger.DebugContext(ctx, "getting decision - resolving registered resource value FQN") + // TODO: implement this case + + default: + p.logger.ErrorContext(ctx, "invalid entity identifier type", slog.String("error", ErrInvalidEntityType.Error()), slog.String("type", fmt.Sprintf("%T", entityIdentifier.GetIdentifier()))) + return nil, false, ErrInvalidEntityType + } + if err != nil { + p.logger.ErrorContext(ctx, "failed to resolve entity identifier", slog.String("error", err.Error())) + return nil, false, fmt.Errorf("failed to resolve entity identifier: %w", err) + } + + var decisions []*Decision + allPermitted := true + for _, entityRep := range entityRepresentations { + d, err := p.pdp.GetDecision(ctx, entityRep, action, resources) + if err != nil { + p.logger.ErrorContext(ctx, "failed to get decision", slog.String("error", err.Error())) + return nil, false, fmt.Errorf("failed to get decision: %w", err) + } + if d == nil { + p.logger.ErrorContext(ctx, "decision is nil") + return nil, false, fmt.Errorf("decision is nil: %w", err) + } + if !d.Access { + allPermitted = false + } + // Decisions should be granular, so do not globally pass or fail + decisions = append(decisions, d) + } + + return decisions, allPermitted, nil +} + +// GetEntitlements retrieves the entitlements for the provided entity chain. +// It resolves the entity chain to get the entity representations and then calls the embedded PDP to get the entitlements. +func (p *JustInTimePDP) GetEntitlements( + ctx context.Context, + entityIdentifier *authzV2.EntityIdentifier, + withComprehensiveHierarchy bool, +) ([]*authzV2.EntityEntitlements, error) { + p.logger.DebugContext(ctx, "getting entitlements - resolving entity chain") + + var ( + entityRepresentations []*entityresolutionV2.EntityRepresentation + err error + skipEnvironmentEntities = false + ) + + switch entityIdentifier.GetIdentifier().(type) { + case *authzV2.EntityIdentifier_EntityChain: + entityRepresentations, err = p.resolveEntitiesFromEntityChain(ctx, entityIdentifier.GetEntityChain(), skipEnvironmentEntities) + case *authzV2.EntityIdentifier_Token: + entityRepresentations, err = p.resolveEntitiesFromToken(ctx, entityIdentifier.GetToken(), skipEnvironmentEntities) + case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: + p.logger.DebugContext(ctx, "getting decision - resolving registered resource value FQN") + // TODO: implement this case + default: + p.logger.ErrorContext(ctx, "invalid entity identifier type", slog.String("error", ErrInvalidEntityType.Error()), slog.String("type", fmt.Sprintf("%T", entityIdentifier.GetIdentifier()))) + return nil, ErrInvalidEntityType + } + if err != nil { + p.logger.ErrorContext(ctx, "failed to resolve entity identifier", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to resolve entity identifier: %w", err) + } + + matchedSubjectMappings, err := p.getMatchedSubjectMappings(ctx, entityRepresentations) + if err != nil { + p.logger.ErrorContext(ctx, "failed to get matched subject mappings", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to get matched subject mappings: %w", err) + } + // If no subject mappings are found, return empty entitlements + if matchedSubjectMappings == nil { + p.logger.ErrorContext(ctx, "matched subject mappings is empty") + return nil, nil + } + + entitlements, err := p.pdp.GetEntitlements(ctx, entityRepresentations, matchedSubjectMappings, withComprehensiveHierarchy) + if err != nil { + p.logger.ErrorContext(ctx, "failed to get entitlements", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to get entitlements: %w", err) + } + return entitlements, nil +} + +// getMatchedSubjectMappings retrieves the subject mappings for the provided entity representations +func (p *JustInTimePDP) getMatchedSubjectMappings( + ctx context.Context, + entityRepresentations []*entityresolutionV2.EntityRepresentation, + // updated with the results, attrValue FQN to attribute and value with subject mappings + // entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, +) ([]*policy.SubjectMapping, error) { + // Break the entity down the entities into their properties/selectors and retrieve only those subject mappings + subjectProperties := make([]*policy.SubjectProperty, 0) + subjectPropertySet := make(map[string]struct{}) + for _, entityRep := range entityRepresentations { + for _, entity := range entityRep.GetAdditionalProps() { + flattened, err := flattening.Flatten(entity.AsMap()) + if err != nil { + p.logger.ErrorContext(ctx, "failed to flatten entity representation", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to flatten entity representation: %w", err) + } + for _, item := range flattened.Items { + if _, ok := subjectPropertySet[item.Key]; !ok { + subjectProperties = append(subjectProperties, &policy.SubjectProperty{ + ExternalSelectorValue: item.Key, + }) + } + } + } + } + + // Greedily retrieve the filtered subject mappings that match one of the subject properties + req := &subjectmapping.MatchSubjectMappingsRequest{ + SubjectProperties: subjectProperties, + } + rsp, err := p.sdk.SubjectMapping.MatchSubjectMappings(ctx, req) + if err != nil { + p.logger.ErrorContext(ctx, "failed to match subject mappings", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to match subject mappings: %w", err) + } + return rsp.GetSubjectMappings(), nil +} + +// fetchAllDefinitions retrieves all attribute definitions within policy +func (p *JustInTimePDP) fetchAllDefinitions(ctx context.Context) ([]*policy.Attribute, error) { + // If quantity of attributes exceeds maximum list pagination, all are needed to determine entitlements + var nextOffset int32 + attrsList := make([]*policy.Attribute, 0) + + for { + listed, err := p.sdk.Attributes.ListAttributes(ctx, &attrs.ListAttributesRequest{ + State: common.ActiveStateEnum_ACTIVE_STATE_ENUM_ACTIVE, + // defer to service default for limit pagination + Pagination: &policy.PageRequest{ + Offset: nextOffset, + }, + }) + if err != nil { + p.logger.ErrorContext(ctx, "failed to list attributes", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to list attributes: %w", err) + } + + nextOffset = listed.GetPagination().GetNextOffset() + attrsList = append(attrsList, listed.GetAttributes()...) + + if nextOffset <= 0 { + break + } + } + return attrsList, nil +} + +// fetchAllSubjectMappings retrieves all attribute values' subject mappings within policy +func (p *JustInTimePDP) fetchAllSubjectMappings(ctx context.Context) ([]*policy.SubjectMapping, error) { + // If quantity of attributes exceeds maximum list pagination, all are needed to determine entitlements + var nextOffset int32 + smList := make([]*policy.SubjectMapping, 0) + + for { + listed, err := p.sdk.SubjectMapping.ListSubjectMappings(ctx, &subjectmapping.ListSubjectMappingsRequest{ + // defer to service default for limit pagination + Pagination: &policy.PageRequest{ + Offset: nextOffset, + }, + }) + if err != nil { + p.logger.ErrorContext(ctx, "failed to list subject mappings", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to list subject mappings: %w", err) + } + + nextOffset = listed.GetPagination().GetNextOffset() + smList = append(smList, listed.GetSubjectMappings()...) + + if nextOffset <= 0 { + break + } + } + return smList, nil +} + +// resolveEntitiesFromEntityChain roundtrips to ERS to resolve the provided entity chain +// and optionally skips environment entities (which is expected behavior in decision flow) +func (p *JustInTimePDP) resolveEntitiesFromEntityChain( + ctx context.Context, + entityChain *entity.EntityChain, + skipEnvironmentEntities bool, +) ([]*entityresolutionV2.EntityRepresentation, error) { + // TODO: is it safe to log the entity chain? + p.logger.DebugContext(ctx, "resolving entities from entity chain", slog.String("entityChain", entityChain.String()), slog.Bool("skipEnvironmentEntities", skipEnvironmentEntities)) + + var filteredEntities []*entity.Entity + if skipEnvironmentEntities { + for _, chained := range entityChain.GetEntities() { + if chained.GetCategory() == entity.Entity_CATEGORY_ENVIRONMENT { + continue + } + filteredEntities = append(filteredEntities, chained) + } + } else { + filteredEntities = entityChain.GetEntities() + } + if len(filteredEntities) == 0 { + return nil, errors.New("no subject entities to resolve - all were environment entities and skipped") + } + + ersResp, err := p.sdk.EntityResolutionV2.ResolveEntities(ctx, &entityresolutionV2.ResolveEntitiesRequest{Entities: filteredEntities}) + if err != nil { + return nil, fmt.Errorf("failed to resolve entities: %w", err) + } + entityRepresentations := ersResp.GetEntityRepresentations() + if entityRepresentations == nil { + return nil, fmt.Errorf("failed to get entity representations: %w", err) + } + return entityRepresentations, nil +} + +// resolveEntitiesFromToken roundtrips to ERS to resolve the provided token +// and optionally skips environment entities (which is expected behavior in decision flow) +func (p *JustInTimePDP) resolveEntitiesFromToken( + ctx context.Context, + token *entity.Token, + skipEnvironmentEntities bool, +) ([]*entityresolutionV2.EntityRepresentation, error) { + // WARNING: do not log the token JWT, just its ID + p.logger.DebugContext(ctx, "resolving entities from token", slog.String("token ephemeral id", token.GetEphemeralId())) + ersResp, err := p.sdk.EntityResolutionV2.CreateEntityChainsFromTokens(ctx, &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: []*entity.Token{token}}) + if err != nil { + return nil, fmt.Errorf("failed to create entity chains from token: %w", err) + } + entityChains := ersResp.GetEntityChains() + if len(entityChains) != 1 { + return nil, fmt.Errorf("received %d entity chains in ERS response and expected exactly 1: %w", len(entityChains), err) + } + return p.resolveEntitiesFromEntityChain(ctx, entityChains[0], skipEnvironmentEntities) +} diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go new file mode 100644 index 0000000000..583f6cbba6 --- /dev/null +++ b/service/internal/access/v2/pdp.go @@ -0,0 +1,317 @@ +package access + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + 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/service/internal/subjectmappingbuiltin" + "github.com/opentdf/platform/service/logger" +) + +// Decision represents the overall access decision for an entity. +type Decision struct { + Access bool `json:"access" example:"false"` + Results []ResourceDecision `json:"entity_rule_result"` +} + +// 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"` + DataRuleResults []DataRuleResult `json:"data_rule_results"` +} + +// DataRuleResult represents the result of evaluating one rule for an entity. +type DataRuleResult struct { + Passed bool `json:"passed" example:"false"` + RuleDefinition *policy.Attribute `json:"rule_definition"` + EntitlementFailures []EntitlementFailure `json:"entitlement_failures"` +} + +// EntitlementFailure represents a failure to satisfy an entitlement of the action on the attribute value. +type EntitlementFailure struct { + AttributeValueFQN string `json:"attribute_value"` + ActionName string `json:"action"` +} + +// PolicyDecisionPoint represents the Policy Decision Point component with all of policy passed in by the caller. +// All decisions and entitlements are evaluated against the in-memory policy. +type PolicyDecisionPoint struct { + logger *logger.Logger + allEntitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + // allRegisteredResourcesByValueFQN map[string]*policy.RegisteredResourceValue +} + +var defaultFallbackLoggerConfig = logger.Config{ + Level: "info", + Type: "json", + Output: "stdout", +} + +// PolicyDecisionPoint 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. +func NewPolicyDecisionPoint( + ctx context.Context, + l *logger.Logger, + allAttributeDefinitions []*policy.Attribute, + allSubjectMappings []*policy.SubjectMapping, + // TODO: take in all registered resources and store them in memory by value FQN + // allRegisteredResources []*policy.RegisteredResource, +) (*PolicyDecisionPoint, error) { + var err error + + if l == nil { + l, err = logger.NewLogger(defaultFallbackLoggerConfig) + if err != nil { + return nil, fmt.Errorf("failed to initialize new PDP logger and none was provided: %w", err) + } + } + + if allAttributeDefinitions == nil || allSubjectMappings == nil { + // if (allAttributeDefinitions != nil && allSubjectMappings == nil) || + // (allAttributeDefinitions == nil && allSubjectMappings != nil) || + // (allAttributeDefinitions == nil && allSubjectMappings == nil) { + l.ErrorContext(ctx, "invalid arguments", slog.String("error", ErrMissingRequiredPolicy.Error())) + return nil, ErrMissingRequiredPolicy + } + + // Build lookup maps to in-memory policy + allAttributesByDefinitionFQN := make(map[string]*policy.Attribute) + allEntitleableAttributesByValueFQN := make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) + for _, attr := range allAttributeDefinitions { + if err := validateAttribute(attr); err != nil { + l.Error("invalid attribute definition", slog.String("error", err.Error())) + return nil, fmt.Errorf("invalid attribute definition: %w", err) + } + allAttributesByDefinitionFQN[attr.GetFqn()] = attr + + // Not every value may have a subject mapping and be entitleable, but a lookup must still be possible + for _, value := range attr.GetValues() { + mapped := &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Value: value, + Attribute: attr, + } + allEntitleableAttributesByValueFQN[value.GetFqn()] = mapped + } + } + + for _, sm := range allSubjectMappings { + if err := validateSubjectMapping(sm); err != nil { + l.WarnContext(ctx, "invalid subject mapping - skipping", slog.String("error", err.Error()), slog.Any("subject mapping", sm)) + continue + } + mappedValue := sm.GetAttributeValue() + mappedValueFQN := mappedValue.GetFqn() + if _, ok := allEntitleableAttributesByValueFQN[mappedValueFQN]; ok { + allEntitleableAttributesByValueFQN[mappedValueFQN].Value.SubjectMappings = append(allEntitleableAttributesByValueFQN[mappedValueFQN].Value.SubjectMappings, sm) + continue + } + // Take subject mapping's attribute value and its definition from memory + parentDefinition, err := getDefinition(mappedValueFQN, allAttributesByDefinitionFQN) + if err != nil { + l.Error("failed to get attribute definition", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to get attribute definition: %w", err) + } + mappedValue.SubjectMappings = []*policy.SubjectMapping{sm} + mapped := &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + Value: mappedValue, + Attribute: parentDefinition, + } + allEntitleableAttributesByValueFQN[mappedValueFQN] = mapped + } + + pdp := &PolicyDecisionPoint{ + l, + allEntitleableAttributesByValueFQN, + } + return pdp, nil +} + +// GetDecision evaluates the action on the resources for the entity and returns a decision. +func (p *PolicyDecisionPoint) GetDecision( + ctx context.Context, + entityRepresentation *entityresolutionV2.EntityRepresentation, + action *policy.Action, + resources []*authz.Resource, +) (*Decision, error) { + loggable := []any{ + slog.String("entity ID", entityRepresentation.GetOriginalId()), + slog.String("action", action.GetName()), + slog.Int("resources total", len(resources)), + } + p.logger.DebugContext(ctx, "getting decision", loggable...) + + if err := validateGetDecision(entityRepresentation, action, resources); err != nil { + p.logger.ErrorContext(ctx, "invalid input parameters", append(loggable, slog.String("error", err.Error()))...) + return nil, err + } + + // Filter all attributes down to only those that relevant to the entitlement decisioning of these specific resources + decisionableAttributes := make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) + + for idx, resource := range resources { + // Assign indexed ephemeral ID for resource if not already set + if resource.GetEphemeralId() == "" { + resource.EphemeralId = "resource-" + strconv.Itoa(idx) + } + + switch resource.GetResource().(type) { + case *authz.Resource_RegisteredResourceValueFqn: + // TODO: handle gathering decisionable attributes of registered resources + + case *authz.Resource_AttributeValues_: + for _, valueFQN := range resource.GetAttributeValues().GetFqns() { + valueFQN = strings.ToLower(valueFQN) + // If same value FQN more than once, skip + if _, ok := decisionableAttributes[valueFQN]; ok { + continue + } + + attributeAndValue, ok := p.allEntitleableAttributesByValueFQN[valueFQN] + if !ok { + loggable = append(loggable, slog.String("error", ErrInvalidResource.Error()), slog.String("value", valueFQN), slog.Any("resource", resource)) + p.logger.ErrorContext(ctx, "resource value FQN not found in memory", loggable...) + return nil, ErrInvalidResource + } + + decisionableAttributes[valueFQN] = attributeAndValue + err := populateHigherValuesIfHierarchy(ctx, p.logger, valueFQN, attributeAndValue.GetAttribute(), p.allEntitleableAttributesByValueFQN, decisionableAttributes) + if err != nil { + loggable = append(loggable, slog.String("error", err.Error()), slog.String("value", valueFQN), slog.Any("resource", resource)) + p.logger.ErrorContext(ctx, "error populating higher hierarchy attribute values", loggable...) + return nil, err + } + } + + default: + // default should never happen as we validate above + p.logger.ErrorContext(ctx, "invalid resource type", append(loggable, slog.String("error", ErrInvalidResource.Error()), slog.Any("resource", resource))...) + return nil, ErrInvalidResource + } + } + p.logger.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable attribute values count", len(decisionableAttributes))) + // Resolve them to their entitled FQNs and the actions available on each + entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation) + if err != nil { + // TODO: is it safe to log entities/entity representations? + p.logger.ErrorContext(ctx, "error evaluating subject mappings for entitlement", append(loggable, slog.String("error", err.Error()), slog.Any("entity", entityRepresentation))...) + return nil, err + } + p.logger.DebugContext(ctx, "evaluated subject mappings", slog.String("entity originalId", entityRepresentation.GetOriginalId()), slog.Any("entitled FQNs to actions", entitledFQNsToActions)) + + decision := &Decision{ + Access: true, + Results: make([]ResourceDecision, len(resources)), + } + + for idx, resource := range resources { + resourceDecision, err := getResourceDecision(ctx, p.logger, decisionableAttributes, entitledFQNsToActions, action, resource) + if err != nil || resourceDecision == nil { + p.logger.ErrorContext(ctx, "error evaluating decision", append(loggable, slog.String("error", err.Error()), slog.Any("resource", resource))...) + return nil, err + } + + if !resourceDecision.Passed { + decision.Access = false + } + + decision.Results[idx] = *resourceDecision + } + + p.logger.DebugContext( + ctx, + "decision results", + append(loggable, slog.Any("decision", decision))..., + ) + + return decision, nil +} + +func (p *PolicyDecisionPoint) GetEntitlements( + ctx context.Context, + entityRepresentations []*entityresolutionV2.EntityRepresentation, + optionalMatchedSubjectMappings []*policy.SubjectMapping, + withComprehensiveHierarchy bool, +) ([]*authz.EntityEntitlements, error) { + loggable := []any{ + slog.Int("entities total", len(entityRepresentations)), + slog.Bool("with comprehensive hierarchy", withComprehensiveHierarchy), + } + + err := validateEntityRepresentations(entityRepresentations) + if err != nil { + p.logger.Error("invalid input parameters", append(loggable, slog.String("error", err.Error()))...) + return nil, err + } + + var entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + + // Check entitlement only against the filtered matched subject mappings if provided + if optionalMatchedSubjectMappings != nil { + p.logger.DebugContext(ctx, "getting entitlements with matched subject mappings", loggable...) + entitleableAttributes, err = getFilteredEntitleableAttributes(optionalMatchedSubjectMappings, p.allEntitleableAttributesByValueFQN) + if err != nil { + p.logger.ErrorContext(ctx, "error filtering entitleable attributes from matched subject mappings", append(loggable, slog.String("error", err.Error()))...) + return nil, err + } + } else { + // Otherwise, use all entitleable attributes + p.logger.DebugContext(ctx, "getting entitlements with all subject mappings (unmatched)", loggable...) + entitleableAttributes = p.allEntitleableAttributesByValueFQN + } + + // Resolve them to their entitled FQNs and the actions available on each + entityIDsToFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(entitleableAttributes, entityRepresentations) + if err != nil { + // TODO: is it safe to log entities/entity representations? + p.logger.ErrorContext(ctx, "error evaluating subject mappings for entitlement", append(loggable, slog.String("error", err.Error()), slog.Any("entities", entityRepresentations))...) + return nil, err + } + + var result []*authz.EntityEntitlements + for entityID, fqnsToActions := range entityIDsToFQNsToActions { + actionsPerAttributeValueFqn := make(map[string]*authz.EntityEntitlements_ActionsList) + + for valueFQN, actions := range fqnsToActions { + // If already entitled (such as via a higher entitled comprehensive hierarchy attr value), merge with existing + if alreadyEntitled, ok := actionsPerAttributeValueFqn[valueFQN]; ok { + actions = mergeDeduplicatedActions(alreadyEntitled.GetActions(), actions) + } + entitledActions := &authz.EntityEntitlements_ActionsList{ + Actions: actions, + } + // If hierarchy and already entitled, merge with existing + actionsPerAttributeValueFqn[valueFQN] = entitledActions + + // If comprehensive, populate the lower hierarchy values + if withComprehensiveHierarchy { + err = populateLowerValuesIfHierarchy(valueFQN, entitleableAttributes, entitledActions, actionsPerAttributeValueFqn) + if err != nil { + p.logger.ErrorContext(ctx, "error populating comprehensive lower hierarchy values", + append(loggable, slog.String("error", err.Error()), slog.String("value", valueFQN), slog.String("entityID", entityID))..., + ) + return nil, err + } + } + } + + result = append(result, &authz.EntityEntitlements{ + EphemeralId: entityID, + ActionsPerAttributeValueFqn: actionsPerAttributeValueFqn, + }) + } + p.logger.DebugContext( + ctx, + "entitlement results", + append(loggable, slog.Any("entitlements", result))..., + ) + return result, nil +} diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go new file mode 100644 index 0000000000..ace1deb1ae --- /dev/null +++ b/service/internal/access/v2/pdp_test.go @@ -0,0 +1,1962 @@ +package access + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/opentdf/platform/lib/identifier" + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/policy/actions" + "google.golang.org/protobuf/types/known/structpb" +) + +// Constants for test namespaces +const ( + testBaseNamespace = "test.example.com" + testSecondaryNamespace = "secondary.example.org" +) + +// Helper function to create attribute definition FQNs +func createAttrFQN(namespace, name string) string { + attr := &identifier.FullyQualifiedAttribute{ + Namespace: namespace, + Name: name, + } + return attr.FQN() +} + +// Helper function to create attribute value FQNs +func createAttrValueFQN(namespace, name, value string) string { + attr := &identifier.FullyQualifiedAttribute{ + Namespace: namespace, + Name: name, + Value: value, + } + return attr.FQN() +} + +// Attribute FQNs using identifier package +var ( + // Base attribute FQNs + testClassificationFQN = createAttrFQN(testBaseNamespace, "classification") + testDepartmentFQN = createAttrFQN(testBaseNamespace, "department") + testCountryFQN = createAttrFQN(testBaseNamespace, "country") + + // Additional attributes from secondary namespace + testProjectFQN = createAttrFQN(testSecondaryNamespace, "project") + testPlatformFQN = createAttrFQN(testSecondaryNamespace, "platform") + + // Classification values + testClassTopSecretFQN = createAttrValueFQN(testBaseNamespace, "classification", "topsecret") + testClassSecretFQN = createAttrValueFQN(testBaseNamespace, "classification", "secret") + testClassConfidentialFQN = createAttrValueFQN(testBaseNamespace, "classification", "confidential") + testClassPublicFQN = createAttrValueFQN(testBaseNamespace, "classification", "public") + + // Department values + testDeptRnDFQN = createAttrValueFQN(testBaseNamespace, "department", "rnd") + testDeptEngineeringFQN = createAttrValueFQN(testBaseNamespace, "department", "engineering") + testDeptSalesFQN = createAttrValueFQN(testBaseNamespace, "department", "sales") + testDeptFinanceFQN = createAttrValueFQN(testBaseNamespace, "department", "finance") + + // Country values + testCountryUSAFQN = createAttrValueFQN(testBaseNamespace, "country", "usa") + testCountryUKFQN = createAttrValueFQN(testBaseNamespace, "country", "uk") + + // Project values in secondary namespace + testProjectAlphaFQN = createAttrValueFQN(testSecondaryNamespace, "project", "alpha") + testProjectBetaFQN = createAttrValueFQN(testSecondaryNamespace, "project", "beta") + testProjectGammaFQN = createAttrValueFQN(testSecondaryNamespace, "project", "gamma") + + // Platform values in secondary namespace + testPlatformCloudFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "cloud") + testPlatformOnPremFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "onprem") + testPlatformHybridFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "hybrid") +) + +// Standard action definitions used across tests +var ( + testActionRead = &policy.Action{Name: actions.ActionNameRead} + testActionCreate = &policy.Action{Name: actions.ActionNameCreate} + testActionUpdate = &policy.Action{Name: actions.ActionNameUpdate} + testActionDelete = &policy.Action{Name: actions.ActionNameDelete} +) + +// PDPTestSuite contains all the tests for the PolicyDecisionPoint +type PDPTestSuite struct { + suite.Suite + logger *logger.Logger + fixtures struct { + // Test attributes + classificationAttr *policy.Attribute + departmentAttr *policy.Attribute + countryAttr *policy.Attribute + projectAttr *policy.Attribute + platformAttr *policy.Attribute + + // Test subject mappings + secretMapping *policy.SubjectMapping + confidentialMapping *policy.SubjectMapping + publicMapping *policy.SubjectMapping + engineeringMapping *policy.SubjectMapping + financeMapping *policy.SubjectMapping + rndMapping *policy.SubjectMapping + usaMapping *policy.SubjectMapping + ukMapping *policy.SubjectMapping + projectAlphaMapping *policy.SubjectMapping + platformCloudMapping *policy.SubjectMapping + + // Test entity representations + adminEntity *entityresolutionV2.EntityRepresentation + developerEntity *entityresolutionV2.EntityRepresentation + analystEntity *entityresolutionV2.EntityRepresentation + } +} + +// SetupTest initializes the test suite +func (s *PDPTestSuite) SetupTest() { + s.logger = logger.CreateTestLogger() + + // Initialize attributes + s.fixtures.classificationAttr = &policy.Attribute{ + Fqn: testClassificationFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: []*policy.Value{ + { + Fqn: testClassTopSecretFQN, + Value: "topsecret", + }, + { + Fqn: testClassSecretFQN, + Value: "secret", + }, + { + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + { + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + } + s.fixtures.departmentAttr = &policy.Attribute{ + Fqn: testDepartmentFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{ + { + Fqn: testDeptRnDFQN, + Value: "rnd", + }, + { + Fqn: testDeptEngineeringFQN, + Value: "engineering", + }, + { + Fqn: testDeptSalesFQN, + Value: "sales", + }, + { + Fqn: testDeptFinanceFQN, + Value: "finance", + }, + }, + } + s.fixtures.countryAttr = &policy.Attribute{ + Fqn: testCountryFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{ + { + Fqn: testCountryUSAFQN, + Value: "usa", + }, + { + Fqn: testCountryUKFQN, + Value: "uk", + }, + }, + } + s.fixtures.projectAttr = &policy.Attribute{ + Fqn: testProjectFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{ + { + Fqn: testProjectAlphaFQN, + Value: "alpha", + }, + { + Fqn: testProjectBetaFQN, + Value: "beta", + }, + { + Fqn: testProjectGammaFQN, + Value: "gamma", + }, + }, + } + s.fixtures.platformAttr = &policy.Attribute{ + Fqn: testPlatformFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{ + { + Fqn: testPlatformCloudFQN, + Value: "cloud", + }, + { + Fqn: testPlatformOnPremFQN, + Value: "onprem", + }, + { + Fqn: testPlatformHybridFQN, + Value: "hybrid", + }, + }, + } + + // Initialize subject mappings + s.fixtures.secretMapping = createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.clearance", + []string{"secret"}, + ) + + s.fixtures.confidentialMapping = createSimpleSubjectMapping( + testClassConfidentialFQN, + "confidential", + []*policy.Action{testActionRead}, + ".properties.clearance", + []string{"confidential"}, + ) + + s.fixtures.publicMapping = createSimpleSubjectMapping( + testClassPublicFQN, + "public", + []*policy.Action{testActionRead}, + ".properties.clearance", + []string{"public"}, + ) + + s.fixtures.engineeringMapping = createSimpleSubjectMapping( + testDeptEngineeringFQN, + "engineering", + []*policy.Action{testActionRead, testActionCreate}, + ".properties.department", + []string{"engineering"}, + ) + + s.fixtures.financeMapping = createSimpleSubjectMapping( + testDeptFinanceFQN, + "finance", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.department", + []string{"finance"}, + ) + + s.fixtures.rndMapping = createSimpleSubjectMapping( + testDeptRnDFQN, + "rnd", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.department", + []string{"rnd"}, + ) + + s.fixtures.usaMapping = createSimpleSubjectMapping( + testCountryUSAFQN, + "usa", + []*policy.Action{testActionRead}, + ".properties.country[]", + []string{"us"}, + ) + + s.fixtures.ukMapping = createSimpleSubjectMapping( + testCountryUKFQN, + "uk", + []*policy.Action{testActionRead}, + ".properties.country[]", + []string{"uk"}, + ) + + s.fixtures.projectAlphaMapping = createSimpleSubjectMapping( + testProjectAlphaFQN, + "alpha", + []*policy.Action{testActionRead, testActionCreate}, + ".properties.project", + []string{"alpha"}, + ) + + s.fixtures.platformCloudMapping = createSimpleSubjectMapping( + testPlatformCloudFQN, + "cloud", + []*policy.Action{testActionRead, testActionDelete}, + ".properties.platform", + []string{"cloud"}, + ) + + // Initialize standard test entities + s.fixtures.adminEntity = s.createEntityWithProps("admin-entity", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, + }) + s.fixtures.developerEntity = s.createEntityWithProps("developer-entity", map[string]interface{}{ + "clearance": "confidential", + "department": "engineering", + "country": []any{"us"}, + }) + s.fixtures.analystEntity = s.createEntityWithProps("analyst-entity", map[string]interface{}{ + "clearance": "confidential", + "department": "finance", + "country": []any{"uk"}, + }) +} + +// TestPDPSuite runs the test suite +func TestPDPSuite(t *testing.T) { + suite.Run(t, new(PDPTestSuite)) +} + +// TestNewPolicyDecisionPoint tests the creation of a new PDP +func (s *PDPTestSuite) TestNewPolicyDecisionPoint() { + f := s.fixtures + + tests := []struct { + name string + attributes []*policy.Attribute + subjectMappings []*policy.SubjectMapping + expectError bool + }{ + { + name: "valid initialization", + attributes: []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr}, + subjectMappings: []*policy.SubjectMapping{f.secretMapping, f.confidentialMapping, f.rndMapping}, + expectError: false, + }, + { + name: "nil attributes and subject mappings", + attributes: nil, + subjectMappings: nil, + expectError: true, + }, + { + name: "nil attributes but non-nil subject mappings", + attributes: nil, + subjectMappings: []*policy.SubjectMapping{f.secretMapping}, + expectError: true, + }, + { + name: "non-nil attributes but nil subject mappings", + attributes: []*policy.Attribute{f.classificationAttr}, + subjectMappings: nil, + expectError: true, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + pdp, err := NewPolicyDecisionPoint(s.T().Context(), s.logger, tc.attributes, tc.subjectMappings) + + if tc.expectError { + s.Require().Error(err) + s.Nil(pdp) + } else { + s.Require().NoError(err) + s.NotNil(pdp) + } + }) + } +} + +// Test_GetDecision tests the GetDecision method with some generalized scenarios +func (s *PDPTestSuite) Test_GetDecision() { + f := s.fixtures + + // Create a PDP with relevant attributes and mappings + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr}, + []*policy.SubjectMapping{f.secretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping}, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("Access granted when entity has appropriate entitlements", func() { + // Entity with appropriate entitlements + entity := s.createEntityWithProps("test-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + }) + + // Resource to evaluate (Secret classification) + resources := createResources(testClassSecretFQN) + + // Get decision + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 1) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: true, + } + s.assertAllDecisionResults(decision, expectedResults) + s.Empty(decision.Results[0].DataRuleResults[0].EntitlementFailures) + }) + + s.Run("Access denied when entity lacks entitlements", func() { + // Entity with insufficient entitlements + entity := s.createEntityWithProps("test-user-2", map[string]interface{}{ + "clearance": "confidential", // Not high enough for update on secret + "department": "finance", // Not engineering + }) + + // Resource to evaluate (Secret classification) + resources := createResources(testClassSecretFQN) + + // Get decision for update action + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 1) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: false, + } + s.assertAllDecisionResults(decision, expectedResults) + s.NotEmpty(decision.Results[0].DataRuleResults[0].EntitlementFailures) + }) + + s.Run("Access denied for disallowed action", func() { + // Entity with appropriate entitlements + entity := s.createEntityWithProps("test-user-3", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + }) + + // Resource to evaluate (Engineering department) + resources := createResources(testDeptEngineeringFQN) + + // Get decision for update action (not allowed on engineering) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 1) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testDeptEngineeringFQN: false, + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + s.Run("Multiple resources - partial access", func() { + // Entity with mixed entitlements + entity := s.createEntityWithProps("test-user-4", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + }) + + // Resources to evaluate (Secret classification and Finance department) + resources := createResources(testClassSecretFQN, testDeptFinanceFQN) + + // Get decision + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because one resource is denied + s.Len(decision.Results, 2) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: true, + testDeptFinanceFQN: false, + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + s.Run("Invalid resource FQN", func() { + // Create test entity + entity := s.createEntityWithProps("test-user-5", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + }) + + // Resource with invalid FQN + resources := createResources(testBaseNamespace + "/attr/nonexistent/value/test") + + // Get decision + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + // Assertions + s.Require().Error(err) + s.Nil(decision) + s.Equal(ErrInvalidResource, err) + }) +} + +// Test_GetDecision_AcrossNamespaces tests cross-namespace decisions with various scenarios +func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { + f := s.fixtures + + // Create mappings for additional secondary namespace values + betaMapping := createSimpleSubjectMapping( + testProjectBetaFQN, + "beta", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.project", + []string{"beta"}, + ) + + gammaMapping := createSimpleSubjectMapping( + testProjectGammaFQN, + "gamma", + []*policy.Action{testActionRead, testActionCreate, testActionDelete}, + ".properties.project", + []string{"gamma"}, + ) + + onPremMapping := createSimpleSubjectMapping( + testPlatformOnPremFQN, + "onprem", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.platform", + []string{"onprem"}, + ) + + hybridMapping := createSimpleSubjectMapping( + testPlatformHybridFQN, + "hybrid", + []*policy.Action{testActionRead, testActionCreate, testActionUpdate, testActionDelete}, + ".properties.platform", + []string{"hybrid"}, + ) + + // Create a PDP with attributes and mappings from all namespaces + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr, f.projectAttr, f.platformAttr}, + []*policy.SubjectMapping{ + f.secretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping, + f.projectAlphaMapping, betaMapping, gammaMapping, + f.platformCloudMapping, onPremMapping, hybridMapping, + f.usaMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + // Basic cross-namespace scenarios + s.Run("Cross-namespace access control - full access", func() { + // Entity with entitlements for both namespaces + entity := s.createEntityWithProps("cross-ns-user-1", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", + }) + + // Resources from two different namespaces + resources := createResources(testClassSecretFQN, testProjectAlphaFQN) + + // Request for a common action allowed by both mappings + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 2) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: true, + testProjectAlphaFQN: true, + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + s.Run("Cross-namespace access control - partial access", func() { + // Entity with partial entitlements + entity := s.createEntityWithProps("cross-ns-user-2", map[string]interface{}{ + "clearance": "secret", + "project": "beta", // Not alpha + "platform": "cloud", + }) + + // Resources from two different namespaces + resources := createResources(testClassSecretFQN, testProjectAlphaFQN) + + // Request for read action + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 2) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: true, // Secret is accessible + testProjectAlphaFQN: false, // Project Alpha is not accessible + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + s.Run("Action permitted by one namespace mapping but not the other", func() { + // Entity with entitlements for both namespaces + entity := s.createEntityWithProps("cross-ns-user-3", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", + }) + + // Resources from two different namespaces + resources := createResources(testClassSecretFQN, testProjectAlphaFQN) + + // Create action is permitted for project alpha but not for secret + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionCreate, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 2) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: false, // Secret doesn't allow create + testProjectAlphaFQN: true, // Project Alpha allows create + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + // More complex cross-namespace scenarios + s.Run("Multiple resources from multiple namespaces", func() { + // Entity with full entitlements + entity := s.createEntityWithProps("cross-ns-user-4", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", + }) + + // Multiple resources from different namespaces + resources := createResources( + testClassSecretFQN, + testClassConfidentialFQN, + testProjectAlphaFQN, + testPlatformCloudFQN, + ) + + // Request for delete action - allowed only by platform cloud mapping + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 4) + + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: false, // Secret doesn't allow delete + testClassConfidentialFQN: false, // Confidential doesn't allow delete + testProjectAlphaFQN: false, // Project Alpha doesn't allow delete + testPlatformCloudFQN: true, // Platform Cloud allows delete + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + s.Run("Mixed namespace resources in a single resource", func() { + // Entity with full entitlements + entity := s.createEntityWithProps("cross-ns-user-5", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", + }) + + // A single resource with FQNs from different namespaces + // Set a specific ID for this combined resource + combinedResource := &authz.Resource{ + EphemeralId: "combined-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testProjectAlphaFQN}, + }, + }, + } + + // Request for read action + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // The implementation treats this as a single resource with multiple rules + s.Len(decision.Results, 1) + s.Equal("combined-resource", decision.Results[0].ResourceID) + + // Instead of checking by FQN, confirm all data rule results pass + for _, dataRule := range decision.Results[0].DataRuleResults { + s.True(dataRule.Passed, "All data rules should pass") + s.Empty(dataRule.EntitlementFailures, "There should be no entitlement failures") + } + }) + + // Additional complex scenarios from Test_GetDecision_ComplexNamespaceInteractions + s.Run("Entity with entitlements across three namespaces", func() { + // Entity with entitlements from all three namespaces + entity := s.createEntityWithProps("tri-namespace-entity", map[string]interface{}{ + "clearance": "secret", // from base namespace + "project": "alpha", // from secondary namespace + "platform": "hybrid", // from secondary namespace + "country": []any{"us"}, // ALL_OF rule + "department": "engineering", // ANY_OF rule + }) + + // Resources from all namespaces + resources := createResources( + testClassSecretFQN, // base namespace + testDeptEngineeringFQN, // base namespace + testCountryUSAFQN, // base namespace - ALL_OF + testProjectAlphaFQN, // secondary namespace + testPlatformHybridFQN, // secondary namespace + ) + + // Test read access - should pass for all namespaces + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + // Assertions + s.Require().NoError(err) + s.True(decision.Access) + s.Len(decision.Results, 5) + + decisionResults := map[string]bool{ + testClassSecretFQN: true, // Secret + testDeptEngineeringFQN: true, // Engineering + testCountryUSAFQN: true, // USA + testProjectAlphaFQN: true, // Project Alpha + testPlatformHybridFQN: true, // Platform Hybrid + } + s.assertAllDecisionResults(decision, decisionResults) + + // Test delete access - should only pass for hybrid platform + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + + // Overall access should be denied + s.Require().NoError(err) + s.False(decision.Access) + s.Len(decision.Results, 5) + + // Only hybrid platform allows delete + decisionResults = map[string]bool{ + testClassSecretFQN: false, // Secret - no delete + testDeptEngineeringFQN: false, // Engineering - no delete + testCountryUSAFQN: false, // USA - no delete + testProjectAlphaFQN: false, // Project Alpha - no delete + testPlatformHybridFQN: true, // Platform Hybrid - allows delete + } + s.assertAllDecisionResults(decision, decisionResults) + }) + + s.Run("Resources from all namespaces in a single resource", func() { + // Entity with entitlements from all namespaces + entity := s.createEntityWithProps("multi-ns-entity", map[string]interface{}{ + "clearance": "secret", + "project": "beta", + "platform": "onprem", + "country": []any{"us"}, + }) + + // A single resource with attribute values from different namespaces + combinedResource := &authz.Resource{ + EphemeralId: "combined-multi-ns-resource", // Explicitly set a resource ID + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{ + testClassSecretFQN, // base namespace + testCountryUSAFQN, // base namespace + testProjectBetaFQN, // secondary namespace + testPlatformOnPremFQN, // secondary namespace + }, + }, + }, + } + + // Test read access - should pass for this combined resource + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + + // Assertions + s.Require().NoError(err) + s.True(decision.Access) + + // The implementation treats this as a single resource with multiple rules + s.Len(decision.Results, 1) + s.Equal("combined-multi-ns-resource", decision.Results[0].ResourceID) + + // Instead of checking FQN by FQN, verify all data rules pass + s.Len(decision.Results[0].DataRuleResults, 4) // Should have 4 data rules (one for each FQN) + for _, dataRule := range decision.Results[0].DataRuleResults { + s.True(dataRule.Passed, "All data rules should pass for read action") + s.Empty(dataRule.EntitlementFailures, "There should be no entitlement failures for read action") + } + + // Test update access - should pass for all except country + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + + // Overall access should be denied due to country not supporting update + s.Require().NoError(err) + s.False(decision.Access) + s.Len(decision.Results, 1) + s.Equal("combined-multi-ns-resource", decision.Results[0].ResourceID) + + // There should be 4 data rules, with some failing + s.Len(decision.Results[0].DataRuleResults, 4) + + // Count passes and failures + passCount := 0 + failCount := 0 + for _, dataRule := range decision.Results[0].DataRuleResults { + if dataRule.Passed { + passCount++ + s.Empty(dataRule.EntitlementFailures) + } else { + failCount++ + s.NotEmpty(dataRule.EntitlementFailures) + } + } + + // Expect 3 passes (Secret, Project Beta, Platform OnPrem) and 1 failure (Country USA) + s.Equal(3, passCount, "Should have 3 passing data rules for update action") + s.Equal(1, failCount, "Should have 1 failing data rule for update action") + }) +} + +// TestGetEntitlements tests the functionality of retrieving entitlements for entities +func (s *PDPTestSuite) Test_GetEntitlements() { + f := s.fixtures + + // Create a PDP with attributes and mappings + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr}, + []*policy.SubjectMapping{ + f.secretMapping, f.confidentialMapping, f.publicMapping, + f.engineeringMapping, f.financeMapping, f.rndMapping, + f.usaMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("Entity with multiple entitlements", func() { + // Entity with entitlements for secret clearance, engineering department, and USA country + entity := s.createEntityWithProps("test-entity-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, + }) + + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + + // Assertions + s.Require().NoError(err) + s.Require().NotNil(entitlements) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "test-entity-1") + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + + // Verify entitlements for classification + secretActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassSecretFQN] + s.Require().NotNil(secretActions, "Secret classification entitlements should exist") + s.Contains(actionNames(secretActions.GetActions()), actions.ActionNameRead) + s.Contains(actionNames(secretActions.GetActions()), actions.ActionNameUpdate) + + // Verify entitlements for department + engineeringActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testDeptEngineeringFQN] + s.Require().NotNil(engineeringActions, "Engineering department entitlements should exist") + s.Contains(actionNames(engineeringActions.GetActions()), actions.ActionNameRead) + s.Contains(actionNames(engineeringActions.GetActions()), actions.ActionNameCreate) + + // Verify entitlements for country + usaActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testCountryUSAFQN] + s.Require().NotNil(usaActions, "USA country entitlements should exist") + s.Contains(actionNames(usaActions.GetActions()), actions.ActionNameRead) + }) + + s.Run("Entity with no matching entitlements", func() { + // Entity with no entitlements based on properties + entity := s.createEntityWithProps("test-entity-2", map[string]interface{}{ + "clearance": "unknown", + "department": "unknown", + "country": []any{"unknown"}, + }) + + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + + // Assertions + s.Require().NoError(err) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "test-entity-2") + s.Require().NotNil(entityEntitlement, "Entity should be included in results even with no entitlements") + s.Empty(entityEntitlement.GetActionsPerAttributeValueFqn(), "No attribute value FQNs should be mapped for this entity") + }) + + s.Run("Entity with partial entitlements", func() { + // Entity with some entitlements + entity := s.createEntityWithProps("test-entity-3", map[string]interface{}{ + "clearance": "public", + "department": "sales", // No mapping for sales + }) + + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + + // Assertions + s.Require().NoError(err) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "test-entity-3") + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + + // Verify public classification entitlements exist + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassPublicFQN, "Public classification entitlements should exist") + publicActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassPublicFQN] + s.Contains(actionNames(publicActions.GetActions()), actions.ActionNameRead) + + // Verify sales department entitlements do not exist + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testDeptSalesFQN, "Sales department should not have entitlements") + }) + + s.Run("Multiple entities with various entitlements", func() { + entityCases := []struct { + name string + entityRepresentation *entityresolutionV2.EntityRepresentation + expectedEntitlements []string + }{ + { + name: "admin-entity", + entityRepresentation: f.adminEntity, + expectedEntitlements: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, + }, + { + name: "developer-entity", + entityRepresentation: f.developerEntity, + expectedEntitlements: []string{testClassConfidentialFQN, testDeptEngineeringFQN, testCountryUSAFQN}, + }, + { + name: "analyst-entity", + entityRepresentation: f.analystEntity, + expectedEntitlements: []string{testClassConfidentialFQN, testDeptFinanceFQN}, + }, + } + + for _, entityCase := range entityCases { + s.Run(entityCase.name, func() { + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entityCase.entityRepresentation}, nil, false) + + // Assertions + s.Require().NoError(err) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, entityCase.name) + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + s.Require().Len(entityEntitlement.GetActionsPerAttributeValueFqn(), len(entityCase.expectedEntitlements), "Number of entitlements should match expected") + + // Verify expected entitlements exist + for _, expectedFQN := range entityCase.expectedEntitlements { + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), expectedFQN) + } + }) + } + }) + + s.Run("With comprehensive hierarchy", func() { + // Entity with secret clearance + entity := s.createEntityWithProps("hierarchy-test-entity", map[string]interface{}{ + "clearance": "secret", + }) + + // Get entitlements with comprehensive hierarchy + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, true) + + // Assertions + s.Require().NoError(err) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "hierarchy-test-entity") + s.Require().NotNil(entityEntitlement) + + // With comprehensive hierarchy, the entity should have access to secret and all lower classifications + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassSecretFQN) + + // The function populateLowerValuesIfHierarchy assumes the values in the hierarchy are arranged + // in order from highest to lowest. In our test fixture, that means: + // topsecret > secret > confidential > public + + // Secret clearance should give access to confidential and public (the items lower in the list) + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassConfidentialFQN) + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassPublicFQN) + + // But not to higher classifications + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassTopSecretFQN) + + // Verify the actions for the lower levels match those granted to the secret level + secretActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassSecretFQN] + s.Require().NotNil(secretActions) + + confidentialActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassConfidentialFQN] + s.Require().NotNil(confidentialActions) + + publicActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassPublicFQN] + s.Require().NotNil(publicActions) + + s.Len(secretActions.GetActions(), len(f.secretMapping.GetActions())) + + // The actions should be the same for all levels + s.ElementsMatch( + actionNames(secretActions.GetActions()), + actionNames(confidentialActions.GetActions()), + "Secret and confidential should have the same actions") + + s.ElementsMatch( + actionNames(secretActions.GetActions()), + actionNames(publicActions.GetActions()), + "Secret and public should have the same actions") + }) + + s.Run("With filtered subject mappings", func() { + // Entity with multiple entitlements + entity := s.createEntityWithProps("filtered-test-entity", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, + }) + + // Filter to only classification mappings + filteredMappings := []*policy.SubjectMapping{f.secretMapping, f.confidentialMapping, f.publicMapping} + + // Get entitlements with filtered mappings + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, filteredMappings, false) + + // Assertions + s.Require().NoError(err) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "filtered-test-entity") + s.Require().NotNil(entityEntitlement) + + // Should only have classification entitlements + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassSecretFQN) + + // Should not have department or country entitlements + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testDeptEngineeringFQN) + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testCountryUSAFQN) + }) +} + +func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { + testAdvancedHierarchyNs := "advanced.hier" + hierarchyAttrName := "hierarchy_attr" + actionNameTransmit := "custom_transmit" + customActionGather := "gather" + + hierarchyTestAttrName := createAttrFQN(testAdvancedHierarchyNs, hierarchyAttrName) + + topValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "top") + upperMiddleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "upper-middle") + middleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "middle") + lowerMiddleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "lower-middle") + bottomValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "bottom") + + hierarchyAttribute := &policy.Attribute{ + Fqn: hierarchyTestAttrName, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: []*policy.Value{ + { + Fqn: topValueFQN, + Value: "top", + }, + { + Fqn: upperMiddleValueFQN, + Value: "upper-middle", + }, + { + Fqn: middleValueFQN, + Value: "middle", + }, + { + Fqn: lowerMiddleValueFQN, + Value: "lower-middle", + }, + { + Fqn: bottomValueFQN, + Value: "bottom", + }, + }, + } + + topMapping := createSimpleSubjectMapping( + topValueFQN, + "top", + []*policy.Action{testActionRead}, + ".properties.levels[]", + []string{"top"}, + ) + upperMiddleMapping := createSimpleSubjectMapping( + upperMiddleValueFQN, + "upper-middle", + []*policy.Action{testActionCreate}, + ".properties.levels[]", + []string{"upper-middle"}, + ) + middleMapping := createSimpleSubjectMapping( + middleValueFQN, + "middle", + []*policy.Action{testActionUpdate, {Name: actionNameTransmit}}, + ".properties.levels[]", + []string{"middle"}, + ) + lowerMiddleMapping := createSimpleSubjectMapping( + lowerMiddleValueFQN, + "lower-middle", + []*policy.Action{testActionDelete}, + ".properties.levels[]", + []string{"lower-middle"}, + ) + bottomMapping := createSimpleSubjectMapping( + bottomValueFQN, + "bottom", + []*policy.Action{{Name: customActionGather}}, + ".properties.levels[]", + []string{"bottom"}, + ) + + // Create a PDP with the hierarchy attribute and mappings + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{hierarchyAttribute}, + []*policy.SubjectMapping{ + topMapping, + upperMiddleMapping, + middleMapping, + lowerMiddleMapping, + bottomMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + // Create an entity with every level in the hierarchy + entity := s.createEntityWithProps("hierarchy-test-entity", map[string]interface{}{ + "levels": []any{"top", "upper-middle", "middle", "lower-middle", "bottom"}, + }) + + // Get entitlements for this entity + withComprehensiveHierarchy := true + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, withComprehensiveHierarchy) + s.Require().NoError(err) + s.Require().NotNil(entitlements) + + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "hierarchy-test-entity") + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + s.Require().Len(entityEntitlement.GetActionsPerAttributeValueFqn(), 5, "Number of entitlements should match expected") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), topValueFQN, "Top level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), upperMiddleValueFQN, "Upper-middle level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), middleValueFQN, "Middle level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), lowerMiddleValueFQN, "Lower-middle level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), bottomValueFQN, "Bottom level should be present") + + // Verify actions for each level + topActions := entityEntitlement.GetActionsPerAttributeValueFqn()[topValueFQN] + s.Require().NotNil(topActions, "Top level actions should exist") + s.Len(topActions.GetActions(), 1) + s.Contains(actionNames(topActions.GetActions()), actions.ActionNameRead, "Top level should have read action") + + upperMiddleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[upperMiddleValueFQN] + s.Require().NotNil(upperMiddleActions, "Upper-middle level actions should exist") + s.Len(upperMiddleActions.GetActions(), 2) + upperMiddleActionNames := actionNames(upperMiddleActions.GetActions()) + s.Contains(upperMiddleActionNames, actions.ActionNameCreate, "Upper-middle level should have create action") + s.Contains(upperMiddleActionNames, actions.ActionNameRead, "Upper-middle level should have read action") + + middleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[middleValueFQN] + s.Require().NotNil(middleActions, "Middle level actions should exist") + s.Len(middleActions.GetActions(), 4) + middleActionNames := actionNames(middleActions.GetActions()) + s.Contains(middleActionNames, actions.ActionNameUpdate, "Middle level should have update action") + s.Contains(middleActionNames, actionNameTransmit, "Middle level should have transmit action") + s.Contains(middleActionNames, actions.ActionNameCreate, "Middle level should have create action") + s.Contains(middleActionNames, actions.ActionNameRead, "Middle level should have read action") + + lowerMiddleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[lowerMiddleValueFQN] + s.Require().NotNil(lowerMiddleActions, "Lower-middle level actions should exist") + s.Len(lowerMiddleActions.GetActions(), 5) + lowerMiddleActionNames := actionNames(lowerMiddleActions.GetActions()) + s.Contains(lowerMiddleActionNames, actions.ActionNameDelete, "Lower-middle level should have delete action") + s.Contains(lowerMiddleActionNames, actions.ActionNameUpdate, "Lower-middle level should have update action") + s.Contains(lowerMiddleActionNames, actions.ActionNameCreate, "Lower-middle level should have create action") + s.Contains(lowerMiddleActionNames, actionNameTransmit, "Lower-middle level should have read action") + s.Contains(lowerMiddleActionNames, actions.ActionNameRead, "Lower-middle level should have read action") + + bottomActions := entityEntitlement.GetActionsPerAttributeValueFqn()[bottomValueFQN] + s.Require().NotNil(bottomActions, "Bottom level actions should exist") + s.Len(bottomActions.GetActions(), 6) + bottomActionNames := actionNames(bottomActions.GetActions()) + s.Contains(bottomActionNames, actions.ActionNameRead, "Bottom level should have read action") + s.Contains(bottomActionNames, actions.ActionNameUpdate, "Bottom level should have update action") + s.Contains(bottomActionNames, actions.ActionNameCreate, "Bottom level should have create action") + s.Contains(bottomActionNames, actions.ActionNameDelete, "Bottom level should have delete action") + s.Contains(bottomActionNames, actionNameTransmit, "Bottom level should have transmit action") + s.Contains(bottomActionNames, customActionGather, "Bottom level should have gather action") +} + +// Test_GetDecision_PartialActionEntitlement tests scenarios where actions only partially align with entitlements +func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { + f := s.fixtures + + // Define a custom print action for testing + testActionPrint := &policy.Action{Name: "print"} + + // Define a custom view action that is a parent of read and list + testActionView := &policy.Action{Name: "view"} + testActionList := &policy.Action{Name: "list"} + testActionSearch := &policy.Action{Name: "search"} + + // Create additional mappings for testing partial action scenarios + printConfidentialMapping := createSimpleSubjectMapping( + testClassConfidentialFQN, + "confidential", + []*policy.Action{testActionRead, testActionPrint}, + ".properties.clearance", + []string{"confidential"}, + ) + + // Create a mapping with a comprehensive set of actions instead of using a wildcard + allActionsPublicMapping := createSimpleSubjectMapping( + testClassPublicFQN, + "public", + []*policy.Action{ + testActionRead, testActionCreate, testActionUpdate, testActionDelete, + testActionPrint, testActionView, testActionList, testActionSearch, + }, + ".properties.clearance", + []string{"public"}, + ) + + // Create a view mapping for Project Alpha with view being a parent action of read and list + viewProjectAlphaMapping := createSimpleSubjectMapping( + testProjectAlphaFQN, + "alpha", + []*policy.Action{testActionView}, + ".properties.project", + []string{"alpha"}, + ) + + // Create a PDP with relevant attributes and mappings + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.projectAttr}, + []*policy.SubjectMapping{ + f.secretMapping, printConfidentialMapping, allActionsPublicMapping, + f.engineeringMapping, f.financeMapping, viewProjectAlphaMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("Scenario 1: User has subset of requested actions", func() { + // Entity with secret clearance - only entitled to read and update on secret + entity := s.createEntityWithProps("user123", map[string]interface{}{ + "clearance": "secret", + }) + + // Resource to evaluate + resources := createResources(testClassSecretFQN) + + decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, resources) + + // Read shuld pass + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) // Should be true because read is allowed + s.Len(decision.Results, 1) + + // Create should fail + decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Should be false because create is not allowed + s.Len(decision.Results, 1) + }) + + s.Run("Scenario 2: User has overlapping action sets", func() { + // Entity with both confidential clearance and finance department + entity := s.createEntityWithProps("user456", map[string]interface{}{ + "clearance": "confidential", + "department": "finance", + }) + + // Create a resource with both confidential and finance attributes + combinedResource := &authz.Resource{ + EphemeralId: "combined-attr-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassConfidentialFQN, testDeptFinanceFQN}, + }, + }, + } + + // Test read access - should be allowed by both attributes + decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 1) + + // Test create access - should be denied (confidential doesn't allow it) + decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall access is denied + + // Test print access - allowed by confidential but not by finance + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionPrint, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall access is denied because one rule fails + + // Test update access - allowed by finance but not by confidential + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall access is denied because one rule fails + + // Test delete access - denied by both + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + }) + + s.Run("Scenario 3: Action inheritance with partial permissions", func() { + // Entity with project alpha access + entity := s.createEntityWithProps("user789", map[string]interface{}{ + "project": "alpha", + }) + + // Resource with project alpha attribute + resources := createResources(testProjectAlphaFQN) + + // Test view access - should be allowed + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionView, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test list access - should be denied + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + + // Test search access - should be denied + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionSearch, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + }) + + s.Run("Scenario 4: Conflicting action policies across multiple attributes", func() { + // Set up a PDP with the comprehensive actions public mapping and restricted mapping + restrictedMapping := createSimpleSubjectMapping( + testClassConfidentialFQN, + "confidential", + []*policy.Action{testActionRead}, // Only read is allowed + ".properties.clearance", + []string{"restricted"}, + ) + + classificationPDP, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr}, + []*policy.SubjectMapping{allActionsPublicMapping, restrictedMapping}, + ) + s.Require().NoError(err) + s.Require().NotNil(classificationPDP) + + // Entity with both public and restricted clearance + entity := s.createEntityWithProps("admin001", map[string]interface{}{ + "clearance": "restricted", + }) + + // Resource with restricted classification + restrictedResources := createResources(testClassConfidentialFQN) + + // Test read access - should be allowed for restricted + decision, err := classificationPDP.GetDecision(s.T().Context(), entity, actionRead, restrictedResources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test create access - should be denied for restricted despite comprehensive actions on public + decision, err = classificationPDP.GetDecision(s.T().Context(), entity, actionCreate, restrictedResources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + + // Test delete access - should be denied for restricted despite comprehensive actions on public + decision, err = classificationPDP.GetDecision(s.T().Context(), entity, testActionDelete, restrictedResources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + }) +} + +// Test_GetDecision_CombinedAttributeRules tests scenarios with combinations of different attribute rules on a single resource +func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() { + f := s.fixtures + + // Create a PDP with all attribute types (HIERARCHY, ANY_OF, ALL_OF) + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr, f.projectAttr, f.platformAttr}, + []*policy.SubjectMapping{ + f.secretMapping, f.confidentialMapping, f.publicMapping, + f.engineeringMapping, f.financeMapping, f.rndMapping, + f.usaMapping, f.ukMapping, f.projectAlphaMapping, f.platformCloudMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("HIERARCHY + ANY_OF combined: Secret classification and Engineering department", func() { + // Entity with proper entitlements for both attributes + entity := s.createEntityWithProps("hier-any-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + }) + + // Single resource with both HIERARCHY (classification) and ANY_OF (department) attributes + combinedResource := &authz.Resource{ + EphemeralId: "secret-engineering-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN}, + }, + }, + } + + // Test read access (both allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test create access (only engineering allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass + + // Test update access (only secret allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass + }) + + s.Run("HIERARCHY + ALL_OF combined: Secret classification and USA country", func() { + // Entity with proper entitlements for both attributes + entity := s.createEntityWithProps("hier-all-user-1", map[string]interface{}{ + "clearance": "secret", + "country": []any{"us", "uk"}, + }) + + // Single resource with both HIERARCHY and ALL_OF attributes + combinedResource := &authz.Resource{ + EphemeralId: "secret-usa-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testCountryUSAFQN}, + }, + }, + } + + // Test read access (both allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test update access (only secret allows, usa doesn't) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass + }) + + s.Run("ANY_OF + ALL_OF combined: Engineering department and USA AND UK country", func() { + // Entity with proper entitlements for both attributes + entity := s.createEntityWithProps("any-all-user-1", map[string]interface{}{ + "department": "engineering", + "country": []any{"us", "uk"}, + }) + + // Single resource with both ANY_OF and ALL_OF attributes + combinedResource := &authz.Resource{ + EphemeralId: "engineering-usa-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN}, + }, + }, + } + + // Test read access (both allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test create access (only engineering allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass + }) + + s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ALL_OF FAILURE", func() { + // Entity with proper entitlements for all three attributes + entity := s.createEntityWithProps("all-rules-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, // does not have UK + }) + + // Single resource with all three attribute rule types, but missing one ALL_OF value FQN + combinedResource := &authz.Resource{ + EphemeralId: "secret-engineering-usa-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN}, + }, + }, + } + + // Test read access (all three allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 1) + + // Drill down proper structure of denial + resourceDecision := decision.Results[0] + s.Require().False(resourceDecision.Passed) + s.Equal("secret-engineering-usa-resource", resourceDecision.ResourceID) + s.Len(resourceDecision.DataRuleResults, 3) + for _, ruleResult := range resourceDecision.DataRuleResults { + switch ruleResult.RuleDefinition.GetFqn() { + case testClassificationFQN: + s.True(ruleResult.Passed) + case testDepartmentFQN: + s.True(ruleResult.Passed) + case testCountryFQN: + s.False(ruleResult.Passed) + } + } + }) + + s.Run("HIERARCHY + ANY_OF + ALL_OF combined - SUCCESS", func() { + // Entity with proper entitlements for all three attributes + entity := s.createEntityWithProps("all-rules-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, + }) + + // Single resource with all three attribute rule types + combinedResource := &authz.Resource{ + EphemeralId: "secret-engineering-usa-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, + }, + }, + } + + // Test read access (all three allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // No other action is permitted by all three attributes + for _, action := range []string{actions.ActionNameCreate, actions.ActionNameUpdate, actions.ActionNameDelete} { + d, err := pdp.GetDecision(s.T().Context(), entity, &policy.Action{Name: action}, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(d) + s.False(d.Access, "Action %s should not be allowed", action) + } + }) + + s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ANY_OF FAILURE", func() { + // Entity with only partial entitlements + entity := s.createEntityWithProps("partial-entitlement-user", map[string]interface{}{ + "clearance": "secret", + "department": "finance", // not matching engineering + "country": []any{"us"}, + }) + + // Resource with all three attribute types + combinedResource := &authz.Resource{ + EphemeralId: "three-attr-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, + }, + }, + } + + // Test read access - should fail because department doesn't match + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + + // Examine which attribute rule failed + s.Len(decision.Results, 1) + s.Equal("three-attr-resource", decision.Results[0].ResourceID) + + // Count passes and failures among data rules + passCount := 0 + failCount := 0 + for _, dataRule := range decision.Results[0].DataRuleResults { + if dataRule.Passed { + passCount++ + } else { + failCount++ + // Check that failure is for country attribute + s.Contains(dataRule.RuleDefinition.GetFqn(), "department") + } + } + s.Equal(2, passCount, "Two attributes should pass") + s.Equal(1, failCount, "One attribute should fail") + }) + + s.Run("Multiple attributes from different namespaces with different rules", func() { + // Entity with cross-namespace entitlements + entity := s.createEntityWithProps("cross-ns-rules-user", map[string]interface{}{ + "clearance": "secret", // HIERARCHY rule + "project": "alpha", // ANY_OF rule from secondary namespace + "platform": "cloud", // ANY_OF rule from secondary namespace + "country": []any{"us"}, // ALL_OF rule + }) + + // Resource with attributes from different namespaces and with different rules + complexResource := &authz.Resource{ + EphemeralId: "complex-multi-ns-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{ + testClassSecretFQN, // HIERARCHY rule, primary namespace + testCountryUSAFQN, // ALL_OF rule, primary namespace + testProjectAlphaFQN, // ANY_OF rule, secondary namespace + testPlatformCloudFQN, // ANY_OF rule, secondary namespace + }, + }, + }, + } + + // Test read access (all four allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{complexResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test delete access (only platform:cloud allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{complexResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall fails because other attributes don't allow delete + + // Count how many attributes passed/failed for delete action + s.Len(decision.Results, 1) + passCount := 0 + failCount := 0 + for _, dataRule := range decision.Results[0].DataRuleResults { + if dataRule.Passed { + passCount++ + // Only the platform attribute should pass for delete + s.Contains(dataRule.RuleDefinition.GetFqn(), "platform") + } else { + failCount++ + } + } + s.Equal(1, passCount, "One attribute should pass (platform:cloud)") + s.Equal(3, failCount, "Three attributes should fail") + }) + + s.Run("Multiple HIERARCHY of duplicate same attribute value", func() { + // Create a resource with multiple classifications (hierarchy rule) + cascadingResource := &authz.Resource{ + EphemeralId: "classification-cascade-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{ + testClassSecretFQN, // Secret classification + testClassSecretFQN, // duplicate + testClassConfidentialFQN, // Confidential classification (lower than Secret) + testClassConfidentialFQN, // second duplicate + }, + }, + }, + } + + // Entity with secret clearance (which should also give access to confidential) + entity := s.createEntityWithProps("secret-entity", map[string]interface{}{ + "clearance": "secret", + }) + + // Test read access + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") + + // Entity with confidential clearance (which should NOT give access to secret) + entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ + "clearance": "confidential", + }) + + // Test read access + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") + + // Verify which rule failed + s.Len(decision.Results, 1) + s.Len(decision.Results[0].DataRuleResults, 1) + ruleResult := decision.Results[0].DataRuleResults[0] + s.NotEmpty(ruleResult.EntitlementFailures) + s.Equal(ruleResult.EntitlementFailures[0].AttributeValueFQN, testClassSecretFQN) + }) + + s.Run("Multiple HIERARCHY of different levels", func() { + // Create a resource with multiple classifications (hierarchy rule) + cascadingResource := &authz.Resource{ + EphemeralId: "classification-cascade-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{ + testClassSecretFQN, // Secret classification + testClassConfidentialFQN, // Confidential classification (lower than Secret) + }, + }, + }, + } + + // Entity with secret clearance (which should also give access to confidential) + entity := s.createEntityWithProps("secret-entity", map[string]interface{}{ + "clearance": "secret", + }) + + // Test read access + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") + + // Entity with confidential clearance (which should NOT give access to secret) + entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ + "clearance": "confidential", + }) + + // Test read access + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") + + // Verify which rule failed + s.Len(decision.Results, 1) + s.Len(decision.Results[0].DataRuleResults, 1) + ruleResult := decision.Results[0].DataRuleResults[0] + s.Len(ruleResult.EntitlementFailures, 1) + s.Equal(ruleResult.EntitlementFailures[0].AttributeValueFQN, testClassSecretFQN) + }) +} + +// Helper functions for all tests + +// assertDecisionResult is a helper function to assert that a decision result for a given FQN matches the expected pass/fail state +func (s *PDPTestSuite) assertDecisionResult(decision *Decision, fqn string, shouldPass bool) { + resourceDecision := findResourceDecision(decision, fqn) + s.Require().NotNil(resourceDecision, "No result found for FQN: "+fqn) + s.Equal(shouldPass, resourceDecision.Passed, "Unexpected result for FQN %s. Expected (%t), got (%t)", fqn, shouldPass, resourceDecision.Passed) +} + +// assertAllDecisionResults tests all FQNs in a map of FQN to expected pass/fail state +func (s *PDPTestSuite) assertAllDecisionResults(decision *Decision, expectedResults map[string]bool) { + for fqn, shouldPass := range expectedResults { + s.assertDecisionResult(decision, fqn, shouldPass) + } + // Verify we didn't miss any results + s.Len(decision.Results, len(expectedResults), "Number of results doesn't match expected count") +} + +// createEntityWithProps creates an entity representation with the specified properties +func (s *PDPTestSuite) createEntityWithProps(entityID string, props map[string]interface{}) *entityresolutionV2.EntityRepresentation { + propsStruct := &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + } + + for k, v := range props { + value, err := structpb.NewValue(v) + if err != nil { + panic(fmt.Sprintf("Failed to convert value %v to structpb.Value: %v", v, err)) + } + propsStruct.Fields[k] = value + } + + return &entityresolutionV2.EntityRepresentation{ + OriginalId: entityID, + AdditionalProps: []*structpb.Struct{ + { + Fields: map[string]*structpb.Value{ + "properties": structpb.NewStructValue(propsStruct), + }, + }, + }, + } +} + +// createResource creates a resource with attribute values +func createResource(ephemeralID string, attributeValueFQNs ...string) *authz.Resource { + return &authz.Resource{ + EphemeralId: ephemeralID, + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: attributeValueFQNs, + }, + }, + } +} + +// createResources creates multiple resources, one for each attribute value FQN +func createResources(attributeValueFQNs ...string) []*authz.Resource { + resources := make([]*authz.Resource, len(attributeValueFQNs)) + for i, fqn := range attributeValueFQNs { + // Use the FQN itself as the resource ID instead of a generic "ephemeral-id-X" + resources[i] = createResource(fqn, fqn) + } + return resources +} + +// actionNames extracts action names from a slice of actions +func actionNames(actions []*policy.Action) []string { + names := make([]string, len(actions)) + for i, action := range actions { + names[i] = action.GetName() + } + return names +} + +// findEntityEntitlements finds entity entitlements by ID +func findEntityEntitlements(entitlements []*authz.EntityEntitlements, entityID string) *authz.EntityEntitlements { + for _, e := range entitlements { + if e != nil && e.GetEphemeralId() == entityID { + return e + } + } + return nil +} + +// createSimpleSubjectConditionSet creates a simple subject condition set with a single condition +// that checks if a property contains any of the specified values +func createSimpleSubjectConditionSet(selector string, values []string) *policy.SubjectConditionSet { + // Create a single condition that uses the IN operator + condition := &policy.Condition{ + SubjectExternalSelectorValue: selector, + SubjectExternalValues: values, + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + } + + // Add the condition to a condition group with AND operator + conditionGroup := &policy.ConditionGroup{ + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + Conditions: []*policy.Condition{condition}, + } + + // Add the condition group to a subject set + subjectSet := &policy.SubjectSet{ + ConditionGroups: []*policy.ConditionGroup{conditionGroup}, + } + + // Return the complete subject condition set + return &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{subjectSet}, + } +} + +// createSimpleSubjectMapping creates a complete subject mapping with a simple condition +func createSimpleSubjectMapping(attrValueFQN string, attrValue string, actions []*policy.Action, selector string, values []string) *policy.SubjectMapping { + return &policy.SubjectMapping{ + AttributeValue: &policy.Value{ + Fqn: attrValueFQN, + Value: attrValue, + }, + SubjectConditionSet: createSimpleSubjectConditionSet(selector, values), + Actions: actions, + } +} + +// Helper function to test decision results +// findResourceDecision finds a decision result for a specific resource ID +func findResourceDecision(decision *Decision, resourceID string) *ResourceDecision { + if decision == nil || len(decision.Results) == 0 { + return nil + } + + // Search for the exact resource ID in the results + for _, result := range decision.Results { + if result.ResourceID == resourceID { + return &result + } + } + return nil +} diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go new file mode 100644 index 0000000000..4cbd03d6d9 --- /dev/null +++ b/service/internal/access/v2/validators.go @@ -0,0 +1,133 @@ +package access + +import ( + "fmt" + + authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" + 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/service/internal/subjectmappingbuiltin" +) + +// validateGetDecision validates the input parameters for GetDecision: +// +// - entityRepresentation: must not be nil +// - action: must not be nil +// - resources: must not be nil and must contain at least one resource +func validateGetDecision(entityRepresentation *entityresolutionV2.EntityRepresentation, action *policy.Action, resources []*authzV2.Resource) error { + if err := validateEntityRepresentations([]*entityresolutionV2.EntityRepresentation{entityRepresentation}); err != nil { + return fmt.Errorf("invalid entity representation: %w", err) + } + if action == nil { + return fmt.Errorf("action is nil: %w", ErrInvalidAction) + } + if len(resources) == 0 { + return fmt.Errorf("resources are empty: %w", ErrInvalidResource) + } + for _, resource := range resources { + if resource == nil { + return fmt.Errorf("resource is nil: %w", ErrInvalidResource) + } + } + return nil +} + +// validateSubjectMapping validates the subject mapping is valid for an entitlement decision +// +// subjectMapping: +// +// - must not be nil +// - must have a non-empty attribute value +// - must have a non-empty attribute value FQN +// - must have a non-empty actions +func validateSubjectMapping(subjectMapping *policy.SubjectMapping) error { + if subjectMapping == nil { + return fmt.Errorf("subject mapping is nil: %w", ErrInvalidSubjectMapping) + } + if subjectMapping.GetAttributeValue() == nil { + return fmt.Errorf("subject mapping's attribute value is nil: %w", ErrInvalidSubjectMapping) + } + if subjectMapping.GetAttributeValue().GetFqn() == "" { + return fmt.Errorf("subject mapping's attribute value FQN is empty: %w", ErrInvalidSubjectMapping) + } + if subjectMapping.GetActions() == nil { + return fmt.Errorf("subject mapping's actions are nil: %w", ErrInvalidSubjectMapping) + } + return nil +} + +// validateAttribute validates the attribute is valid for an entitlement decision +// +// attribute: +// +// - must not be nil +// - must have a non-empty FQN +// - must have non-empty values +// - must have non-empty values FQNs +func validateAttribute(attribute *policy.Attribute) error { + if attribute == nil { + return fmt.Errorf("attribute is nil: %w", ErrInvalidAttributeDefinition) + } + if attribute.GetFqn() == "" { + return fmt.Errorf("attribute FQN is empty: %w", ErrInvalidAttributeDefinition) + } + if len(attribute.GetValues()) == 0 { + return fmt.Errorf("attribute values are empty: %w", ErrInvalidAttributeDefinition) + } + for _, value := range attribute.GetValues() { + if value == nil { + return fmt.Errorf("attribute value is nil: %w", ErrInvalidAttributeDefinition) + } + if value.GetFqn() == "" { + return fmt.Errorf("attribute value FQN is empty: %w", ErrInvalidAttributeDefinition) + } + } + if attribute.GetRule() == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED { + return fmt.Errorf("attribute rule is unspecified: %w", ErrInvalidAttributeDefinition) + } + return nil +} + +// validateEntityRepresentations validates the entity representations are valid for an entitlement decision +// +// - entityRepresentations: must have at least one non-nil entity representation +func validateEntityRepresentations(entityRepresentations []*entityresolutionV2.EntityRepresentation) error { + if len(entityRepresentations) == 0 { + return fmt.Errorf("empty entity chain: %w", ErrInvalidEntityChain) + } + for _, entity := range entityRepresentations { + if entity == nil { + return fmt.Errorf("entity is nil: %w", ErrInvalidEntityChain) + } + } + + return nil +} + +// validateOneResourceDecision validates the parameters for an access decision on a resource +// +// - accessibleAttributeValues: must not be nil +// - entitlements: must not be nil +// - action: must not be nil +// - resource: must not be nil +func validateGetResourceDecision( + accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resource *authzV2.Resource, +) error { + if entitlements == nil { + return fmt.Errorf("entitled FQNs to actions are nil: %w", ErrInvalidEntitledFQNsToActions) + } + if action.GetName() == "" { + return fmt.Errorf("action name required: %w", ErrInvalidAction) + } + if resource.GetResource() == nil { + return fmt.Errorf("resource is nil: %w", ErrInvalidResource) + } + if len(accessibleAttributeValues) == 0 { + return fmt.Errorf("accessible attribute values are empty: %w", ErrMissingRequiredPolicy) + } + return nil +} diff --git a/service/internal/access/v2/validators_test.go b/service/internal/access/v2/validators_test.go new file mode 100644 index 0000000000..5c2ad9c669 --- /dev/null +++ b/service/internal/access/v2/validators_test.go @@ -0,0 +1,427 @@ +package access + +import ( + "testing" + + authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" + 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/service/policy/actions" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestValidateGetDecision(t *testing.T) { + validEntityRepresentation := &entityresolutionV2.EntityRepresentation{ + OriginalId: "entity-id", + AdditionalProps: []*structpb.Struct{ + { + Fields: map[string]*structpb.Value{ + "key": structpb.NewStringValue("value"), + }, + }, + }, + } + + validAction := &policy.Action{ + Name: "read", + } + + validResources := []*authzV2.Resource{ + { + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: []string{"https://example.org/attr/classification/value/public"}, + }, + }, + }, + } + + tests := []struct { + name string + entityRep *entityresolutionV2.EntityRepresentation + action *policy.Action + resources []*authzV2.Resource + wantErr error + }{ + { + name: "Valid inputs", + entityRep: validEntityRepresentation, + action: validAction, + resources: validResources, + wantErr: nil, + }, + { + name: "Nil entity representation", + entityRep: nil, + action: validAction, + resources: validResources, + wantErr: ErrInvalidEntityChain, + }, + { + name: "Nil action", + entityRep: validEntityRepresentation, + action: nil, + resources: validResources, + wantErr: ErrInvalidAction, + }, + { + name: "Empty resources", + entityRep: validEntityRepresentation, + action: validAction, + resources: []*authzV2.Resource{}, + wantErr: ErrInvalidResource, + }, + { + name: "Nil resources", + entityRep: validEntityRepresentation, + action: validAction, + resources: nil, + wantErr: ErrInvalidResource, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGetDecision(tt.entityRep, tt.action, tt.resources) + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateSubjectMapping(t *testing.T) { + validFQN := "https://example.org/attr/classification/value/public" + validValue := &policy.Value{ + Fqn: validFQN, + } + validActions := []*policy.Action{ + { + Id: "action-1", + Name: "read", + }, + } + + tests := []struct { + name string + subjectMapping *policy.SubjectMapping + wantErr error + }{ + { + name: "Valid subject mapping", + subjectMapping: &policy.SubjectMapping{ + AttributeValue: validValue, + Actions: validActions, + }, + wantErr: nil, + }, + { + name: "Nil subject mapping", + subjectMapping: nil, + wantErr: ErrInvalidSubjectMapping, + }, + { + name: "Nil attribute value", + subjectMapping: &policy.SubjectMapping{ + AttributeValue: nil, + Actions: validActions, + }, + wantErr: ErrInvalidSubjectMapping, + }, + { + name: "Empty attribute value FQN", + subjectMapping: &policy.SubjectMapping{ + AttributeValue: &policy.Value{ + Fqn: "", + }, + Actions: validActions, + }, + wantErr: ErrInvalidSubjectMapping, + }, + { + name: "Nil actions", + subjectMapping: &policy.SubjectMapping{ + AttributeValue: validValue, + Actions: nil, + }, + wantErr: ErrInvalidSubjectMapping, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSubjectMapping(tt.subjectMapping) + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateAttribute(t *testing.T) { + validValues := []*policy.Value{ + { + Fqn: "https://example.org/attr/name/value/public", + }, + } + + tests := []struct { + name string + attribute *policy.Attribute + wantErr error + }{ + { + name: "Valid attribute", + attribute: &policy.Attribute{ + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Fqn: "https://example.org/attr/name", + Values: validValues, + }, + wantErr: nil, + }, + { + name: "Unspecified attribute rule", + attribute: &policy.Attribute{ + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED, + Fqn: "https://example.org/attr/name", + Values: validValues, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Missing attribute rule", + attribute: &policy.Attribute{ + Fqn: "https://example.org/attr/name", + Values: validValues, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Nil attribute", + attribute: nil, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Empty attribute FQN", + attribute: &policy.Attribute{ + Fqn: "", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: validValues, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Empty attribute values", + attribute: &policy.Attribute{ + Fqn: "https://example.org/attr/name", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{}, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Nil attribute values", + attribute: &policy.Attribute{ + Fqn: "https://example.org/attr/name", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: nil, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Nil value in attribute values", + attribute: &policy.Attribute{ + Fqn: "https://example.org/attr/name", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{nil}, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + { + name: "Empty FQN in attribute value", + attribute: &policy.Attribute{ + Fqn: "https://example.org/attr/name", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{ + { + Fqn: "", + }, + }, + }, + wantErr: ErrInvalidAttributeDefinition, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAttribute(tt.attribute) + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateEntityRepresentations(t *testing.T) { + tests := []struct { + name string + entityRepresentations []*entityresolutionV2.EntityRepresentation + wantErr error + }{ + { + name: "Valid entity representations", + entityRepresentations: []*entityresolutionV2.EntityRepresentation{{}}, + wantErr: nil, + }, + { + name: "Nil entity representations", + entityRepresentations: nil, + wantErr: ErrInvalidEntityChain, + }, + { + name: "Empty entity representations", + entityRepresentations: []*entityresolutionV2.EntityRepresentation{}, + wantErr: ErrInvalidEntityChain, + }, + { + name: "Entity representation is nil", + entityRepresentations: []*entityresolutionV2.EntityRepresentation{nil}, + wantErr: ErrInvalidEntityChain, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEntityRepresentations(tt.entityRepresentations) + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateGetResourceDecision(t *testing.T) { + // non-nil policy map + validDecisionableAttributes := map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + "https://example.org/attr/classification/value/public": {}, + } + + // non-nil entitlements mapmap + validEntitledFQNsToActions := map[string][]*policy.Action{ + "https://example.org/attr/name/value/public": {}, + } + + // non-nil action + validAction := &policy.Action{ + Name: actions.ActionNameRead, + } + + // non-nil resource + validResource := &authzV2.Resource{ + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: []string{"https://example.org/attr/classification/value/public"}, + }, + }, + } + + tests := []struct { + name string + accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + entitlements map[string][]*policy.Action + action *policy.Action + resource *authzV2.Resource + wantErr error + }{ + { + name: "Valid inputs", + accessibleAttributeValues: validDecisionableAttributes, + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: validResource, + wantErr: nil, + }, + { + name: "Nil accessible attribute values", + accessibleAttributeValues: nil, + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: validResource, + wantErr: ErrMissingRequiredPolicy, + }, + { + name: "Nil entitlements", + accessibleAttributeValues: validDecisionableAttributes, + entitlements: nil, + action: validAction, + resource: validResource, + wantErr: ErrInvalidEntitledFQNsToActions, + }, + { + name: "Nil action", + accessibleAttributeValues: validDecisionableAttributes, + entitlements: validEntitledFQNsToActions, + action: nil, + resource: validResource, + wantErr: ErrInvalidAction, + }, + { + name: "Nil resource", + accessibleAttributeValues: validDecisionableAttributes, + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: nil, + wantErr: ErrInvalidResource, + }, + { + name: "Empty accessible attribute values", + accessibleAttributeValues: map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{}, + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: validResource, + wantErr: ErrMissingRequiredPolicy, + }, + { + name: "Empty action", + accessibleAttributeValues: validDecisionableAttributes, + entitlements: validEntitledFQNsToActions, + action: &policy.Action{}, + resource: validResource, + wantErr: ErrInvalidAction, + }, + { + name: "Empty resource", + accessibleAttributeValues: validDecisionableAttributes, + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: &authzV2.Resource{}, + wantErr: ErrInvalidResource, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGetResourceDecision(tt.accessibleAttributeValues, tt.entitlements, tt.action, tt.resource) + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} From 9bf0e602312628148a3dbdc0824c4f19824a5ac2 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 20 May 2025 12:53:48 -0700 Subject: [PATCH 02/18] put back logging changes --- service/internal/access/pdp.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/service/internal/access/pdp.go b/service/internal/access/pdp.go index 54ccff2acc..137058c726 100644 --- a/service/internal/access/pdp.go +++ b/service/internal/access/pdp.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log/slog" "strings" "github.com/opentdf/platform/protocol/go/policy" @@ -157,21 +156,17 @@ func (pdp *Pdp) evaluateRule( distinctValues []*policy.Value, entityAttributeSets map[string][]string, ) (map[string]DataRuleResult, error) { - pdp.logger.DebugContext(ctx, - "Evaluating attribute definition", - slog.String("name", attrDefinition.GetFqn()), - slog.String("rule", attrDefinition.GetRule().String()), - slog.Any("values", distinctValues), - ) - switch attrDefinition.GetRule() { case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: + pdp.logger.DebugContext(ctx, "Evaluating under allOf", "name", attrDefinition.GetFqn()) return pdp.allOfRule(ctx, distinctValues, entityAttributeSets) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: + pdp.logger.DebugContext(ctx, "Evaluating under anyOf", "name", attrDefinition.GetFqn()) return pdp.anyOfRule(ctx, distinctValues, entityAttributeSets) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: + pdp.logger.DebugContext(ctx, "Evaluating under hierarchy", "name", attrDefinition.GetFqn()) return pdp.hierarchyRule(ctx, distinctValues, entityAttributeSets, attrDefinition.GetValues()) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: From 5cd84cbc91b8b2bc7fe990469e6ea00986afee03 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 20 May 2025 13:47:42 -0700 Subject: [PATCH 03/18] cleanup --- service/internal/access/v2/pdp.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 583f6cbba6..8d48185dd1 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -76,9 +76,6 @@ func NewPolicyDecisionPoint( } if allAttributeDefinitions == nil || allSubjectMappings == nil { - // if (allAttributeDefinitions != nil && allSubjectMappings == nil) || - // (allAttributeDefinitions == nil && allSubjectMappings != nil) || - // (allAttributeDefinitions == nil && allSubjectMappings == nil) { l.ErrorContext(ctx, "invalid arguments", slog.String("error", ErrMissingRequiredPolicy.Error())) return nil, ErrMissingRequiredPolicy } From 00ead46428492c25a239eef7a604fa00d9ce3772 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 20 May 2025 14:44:54 -0700 Subject: [PATCH 04/18] improvements --- service/internal/access/v2/evaluate.go | 13 +- service/internal/access/v2/pdp_test.go | 2136 ++++++++++++------------ 2 files changed, 1033 insertions(+), 1116 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 0781109257..73a9ee65c2 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -37,6 +37,7 @@ func getResourceDecision( case *authz.Resource_RegisteredResourceValueFqn: // TODO: handle registered resources // return evaluateRegisteredResourceValue(ctx, resource.GetRegisteredResourceValueFqn(), action, entitlements, accessibleAttributeValues) + case *authz.Resource_AttributeValues_: return evaluateResourceAttributeValues(ctx, logger, resource.GetAttributeValues(), resource.GetEphemeralId(), action, entitlements, accessibleAttributeValues) @@ -59,19 +60,19 @@ func evaluateResourceAttributeValues( accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, ) (*ResourceDecision, error) { // Group value FQNs by parent definition - groupedByDefinition := make(map[string][]string) + definitionFqnToValueFqns := make(map[string][]string) definitionsLookup := make(map[string]*policy.Attribute) for idx, valueFQN := range resourceAttributeValues.GetFqns() { // lowercase the value FQN to ensure case-insensitive matching valueFQN = strings.ToLower(valueFQN) resourceAttributeValues.Fqns[idx] = valueFQN - attributeAndValue, okvalueFQN := accessibleAttributeValues[valueFQN] - if !okvalueFQN { + attributeAndValue, ok := accessibleAttributeValues[valueFQN] + if !ok { return nil, fmt.Errorf("%w: %s", ErrFQNNotFound, valueFQN) } definition := attributeAndValue.GetAttribute() - groupedByDefinition[definition.GetFqn()] = append(groupedByDefinition[definition.GetFqn()], valueFQN) + definitionFqnToValueFqns[definition.GetFqn()] = append(definitionFqnToValueFqns[definition.GetFqn()], valueFQN) definitionsLookup[definition.GetFqn()] = definition } @@ -79,13 +80,13 @@ func evaluateResourceAttributeValues( passed := true dataRuleResults := make([]DataRuleResult, 0) - for defFQN, valueFQNs := range groupedByDefinition { + for defFQN, resourceValueFQNs := range definitionFqnToValueFqns { definition := definitionsLookup[defFQN] if definition == nil { return nil, fmt.Errorf("%w: %s", ErrDefinitionNotFound, defFQN) } - dataRuleResult, err := evaluateDefinition(ctx, logger, entitlements, action, valueFQNs, definition) + dataRuleResult, err := evaluateDefinition(ctx, logger, entitlements, action, resourceValueFQNs, definition) if err != nil { return nil, fmt.Errorf("%w: %s", ErrFailedEvaluation, err.Error()) } diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index ace1deb1ae..648daaa437 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -99,6 +99,7 @@ type PDPTestSuite struct { platformAttr *policy.Attribute // Test subject mappings + topSecretMapping *policy.SubjectMapping secretMapping *policy.SubjectMapping confidentialMapping *policy.SubjectMapping publicMapping *policy.SubjectMapping @@ -218,6 +219,14 @@ func (s *PDPTestSuite) SetupTest() { } // Initialize subject mappings + s.fixtures.topSecretMapping = createSimpleSubjectMapping( + testClassSecretFQN, + "topsecret", + []*policy.Action{testActionRead}, + ".properties.clearance", + []string{"ts"}, + ) + s.fixtures.secretMapping = createSimpleSubjectMapping( testClassSecretFQN, "secret", @@ -338,7 +347,7 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint() { expectError: false, }, { - name: "nil attributes and subject mappings", + name: "nil attributes and nil subject mappings", attributes: nil, subjectMappings: nil, expectError: true, @@ -372,8 +381,8 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint() { } } -// Test_GetDecision tests the GetDecision method with some generalized scenarios -func (s *PDPTestSuite) Test_GetDecision() { +// Test_GetDecision_MultipleResources tests the GetDecision method with some generalized scenarios for multiple resources +func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { f := s.fixtures // Create a PDP with relevant attributes and mappings @@ -381,1444 +390,1351 @@ func (s *PDPTestSuite) Test_GetDecision() { s.T().Context(), s.logger, []*policy.Attribute{f.classificationAttr, f.departmentAttr}, - []*policy.SubjectMapping{f.secretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping}, + []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping}, ) s.Require().NoError(err) s.Require().NotNil(pdp) - s.Run("Access granted when entity has appropriate entitlements", func() { - // Entity with appropriate entitlements + s.Run("Multiple resources and entitled actions/attributes - full access", func() { entity := s.createEntityWithProps("test-user-1", map[string]interface{}{ - "clearance": "secret", + "clearance": "ts", "department": "engineering", }) - // Resource to evaluate (Secret classification) - resources := createResources(testClassSecretFQN) + resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN) - // Get decision decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) - // Assertions s.Require().NoError(err) s.Require().NotNil(decision) s.True(decision.Access) - s.Len(decision.Results, 1) + s.Len(decision.Results, 2) - // Use FQN-based assertions expectedResults := map[string]bool{ - testClassSecretFQN: true, + testClassSecretFQN: true, + testDeptEngineeringFQN: true, } s.assertAllDecisionResults(decision, expectedResults) - s.Empty(decision.Results[0].DataRuleResults[0].EntitlementFailures) + for _, result := range decision.Results { + s.True(result.Passed, "All data rules should pass") + s.Len(result.DataRuleResults, 1) + s.Empty(result.DataRuleResults[0].EntitlementFailures) + } }) - s.Run("Access denied when entity lacks entitlements", func() { - // Entity with insufficient entitlements + s.Run("Multiple resources and unentitled attributes - full denial", func() { entity := s.createEntityWithProps("test-user-2", map[string]interface{}{ "clearance": "confidential", // Not high enough for update on secret "department": "finance", // Not engineering }) - // Resource to evaluate (Secret classification) - resources := createResources(testClassSecretFQN) + resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN) - // Get decision for update action decision, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) - // Assertions s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 1) + s.Len(decision.Results, 2) - // Use FQN-based assertions expectedResults := map[string]bool{ - testClassSecretFQN: false, + testClassSecretFQN: false, + testDeptEngineeringFQN: false, } + s.assertAllDecisionResults(decision, expectedResults) - s.NotEmpty(decision.Results[0].DataRuleResults[0].EntitlementFailures) + for _, result := range decision.Results { + s.False(result.Passed, "Data rules should not pass") + s.Len(result.DataRuleResults, 1) + s.NotEmpty(result.DataRuleResults[0].EntitlementFailures) + } }) - s.Run("Access denied for disallowed action", func() { - // Entity with appropriate entitlements + s.Run("Multiple resources and unentitled actions - full denial", func() { entity := s.createEntityWithProps("test-user-3", map[string]interface{}{ - "clearance": "secret", + "clearance": "topsecret", "department": "engineering", }) - // Resource to evaluate (Engineering department) - resources := createResources(testDeptEngineeringFQN) + resources := createResourcePerFqn(testDeptEngineeringFQN, testClassSecretFQN) - // Get decision for update action (not allowed on engineering) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) + // Get decision for delete action (not allowed by either attribute's subject mappings) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) - // Assertions s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 1) + s.Len(decision.Results, 2) - // Use FQN-based assertions expectedResults := map[string]bool{ testDeptEngineeringFQN: false, + testClassSecretFQN: false, } s.assertAllDecisionResults(decision, expectedResults) }) s.Run("Multiple resources - partial access", func() { - // Entity with mixed entitlements entity := s.createEntityWithProps("test-user-4", map[string]interface{}{ "clearance": "secret", - "department": "engineering", + "department": "engineering", // not finance }) - // Resources to evaluate (Secret classification and Finance department) - resources := createResources(testClassSecretFQN, testDeptFinanceFQN) + resources := createResourcePerFqn(testClassSecretFQN, testDeptFinanceFQN) - // Get decision decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) - // Assertions s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) // False because one resource is denied s.Len(decision.Results, 2) - // Use FQN-based assertions expectedResults := map[string]bool{ testClassSecretFQN: true, testDeptFinanceFQN: false, } s.assertAllDecisionResults(decision, expectedResults) - }) - - s.Run("Invalid resource FQN", func() { - // Create test entity - entity := s.createEntityWithProps("test-user-5", map[string]interface{}{ - "clearance": "secret", - "department": "engineering", - }) - // Resource with invalid FQN - resources := createResources(testBaseNamespace + "/attr/nonexistent/value/test") - - // Get decision - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + // Validate proper data rule results + for _, result := range decision.Results { + s.Len(result.DataRuleResults, 1) - // Assertions - s.Require().Error(err) - s.Nil(decision) - s.Equal(ErrInvalidResource, err) + if result.ResourceID == testClassSecretFQN { + s.True(result.Passed, "Secret should pass") + s.Empty(result.DataRuleResults[0].EntitlementFailures) + } else if result.ResourceID == testDeptFinanceFQN { + s.False(result.Passed, "Finance should not pass") + s.NotEmpty(result.DataRuleResults[0].EntitlementFailures) + } + } }) } -// Test_GetDecision_AcrossNamespaces tests cross-namespace decisions with various scenarios -func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { +// Test_GetDecision_PartialActionEntitlement tests scenarios where actions only partially align with entitlements +func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { f := s.fixtures - // Create mappings for additional secondary namespace values - betaMapping := createSimpleSubjectMapping( - testProjectBetaFQN, - "beta", - []*policy.Action{testActionRead, testActionUpdate}, - ".properties.project", - []string{"beta"}, - ) + // Define custom actions for testing + testActionPrint := &policy.Action{Name: "print"} + testActionView := &policy.Action{Name: "view"} + testActionList := &policy.Action{Name: "list"} + testActionSearch := &policy.Action{Name: "search"} - gammaMapping := createSimpleSubjectMapping( - testProjectGammaFQN, - "gamma", - []*policy.Action{testActionRead, testActionCreate, testActionDelete}, - ".properties.project", - []string{"gamma"}, + // Create additional mappings for testing partial action scenarios + printConfidentialMapping := createSimpleSubjectMapping( + testClassConfidentialFQN, + "confidential", + []*policy.Action{testActionRead, testActionPrint}, + ".properties.clearance", + []string{"confidential"}, ) - onPremMapping := createSimpleSubjectMapping( - testPlatformOnPremFQN, - "onprem", - []*policy.Action{testActionRead, testActionUpdate}, - ".properties.platform", - []string{"onprem"}, + // Create a mapping with a comprehensive set of actions instead of using a wildcard + allActionsPublicMapping := createSimpleSubjectMapping( + testClassPublicFQN, + "public", + []*policy.Action{ + testActionRead, testActionCreate, testActionUpdate, testActionDelete, + testActionPrint, testActionView, testActionList, testActionSearch, + }, + ".properties.clearance", + []string{"public"}, ) - hybridMapping := createSimpleSubjectMapping( - testPlatformHybridFQN, - "hybrid", - []*policy.Action{testActionRead, testActionCreate, testActionUpdate, testActionDelete}, - ".properties.platform", - []string{"hybrid"}, + // Create a view mapping for Project Alpha with view being a parent action of read and list + viewProjectAlphaMapping := createSimpleSubjectMapping( + testProjectAlphaFQN, + "alpha", + []*policy.Action{testActionView}, + ".properties.project", + []string{"alpha"}, ) - // Create a PDP with attributes and mappings from all namespaces + // Create a PDP with relevant attributes and mappings pdp, err := NewPolicyDecisionPoint( s.T().Context(), s.logger, - []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr, f.projectAttr, f.platformAttr}, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.projectAttr}, []*policy.SubjectMapping{ - f.secretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping, - f.projectAlphaMapping, betaMapping, gammaMapping, - f.platformCloudMapping, onPremMapping, hybridMapping, - f.usaMapping, + f.secretMapping, f.topSecretMapping, printConfidentialMapping, allActionsPublicMapping, + f.engineeringMapping, f.financeMapping, viewProjectAlphaMapping, }, ) s.Require().NoError(err) s.Require().NotNil(pdp) - // Basic cross-namespace scenarios - s.Run("Cross-namespace access control - full access", func() { - // Entity with entitlements for both namespaces - entity := s.createEntityWithProps("cross-ns-user-1", map[string]interface{}{ + s.Run("Scenario 1: User has subset of requested actions", func() { + // Entity with secret clearance - only entitled to read and update on secret + entity := s.createEntityWithProps("user123", map[string]interface{}{ "clearance": "secret", - "project": "alpha", - "platform": "cloud", }) - // Resources from two different namespaces - resources := createResources(testClassSecretFQN, testProjectAlphaFQN) + // Resource to evaluate + resources := createResourcePerFqn(testClassSecretFQN) - // Request for a common action allowed by both mappings - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, resources) - // Assertions + // Read shuld pass s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) - s.Len(decision.Results, 2) + s.True(decision.Access) // Should be true because read is allowed + s.Len(decision.Results, 1) - // Use FQN-based assertions - expectedResults := map[string]bool{ - testClassSecretFQN: true, - testProjectAlphaFQN: true, - } - s.assertAllDecisionResults(decision, expectedResults) + // Create should fail + decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Should be false because create is not allowed + s.Len(decision.Results, 1) }) - s.Run("Cross-namespace access control - partial access", func() { - // Entity with partial entitlements - entity := s.createEntityWithProps("cross-ns-user-2", map[string]interface{}{ - "clearance": "secret", - "project": "beta", // Not alpha - "platform": "cloud", + s.Run("Scenario 2: User has overlapping action sets", func() { + // Entity with both confidential clearance and finance department + entity := s.createEntityWithProps("user456", map[string]interface{}{ + "clearance": "confidential", + "department": "finance", }) - // Resources from two different namespaces - resources := createResources(testClassSecretFQN, testProjectAlphaFQN) + combinedResource := createResource("combined-attr-resource", testClassConfidentialFQN, testDeptFinanceFQN) - // Request for read action - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + // Test read access - should be allowed by both attributes + decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 1) - // Assertions + // Test create access - should be denied (confidential doesn't allow it) + decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) - s.Len(decision.Results, 2) + s.False(decision.Access) // Overall access is denied - // Use FQN-based assertions - expectedResults := map[string]bool{ - testClassSecretFQN: true, // Secret is accessible - testProjectAlphaFQN: false, // Project Alpha is not accessible - } - s.assertAllDecisionResults(decision, expectedResults) + // Test print access - allowed by confidential but not by finance + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionPrint, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall access is denied because one rule fails + + // Test update access - allowed by finance but not by confidential + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall access is denied because one rule fails + + // Test delete access - denied by both + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) }) - s.Run("Action permitted by one namespace mapping but not the other", func() { - // Entity with entitlements for both namespaces - entity := s.createEntityWithProps("cross-ns-user-3", map[string]interface{}{ - "clearance": "secret", - "project": "alpha", - "platform": "cloud", + s.Run("Scenario 3: Action inheritance with partial permissions", func() { + entity := s.createEntityWithProps("user789", map[string]interface{}{ + "project": "alpha", }) - // Resources from two different namespaces - resources := createResources(testClassSecretFQN, testProjectAlphaFQN) + resources := createResourcePerFqn(testProjectAlphaFQN) - // Create action is permitted for project alpha but not for secret - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionCreate, resources) + // Test view access - should be allowed + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionView, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) - // Assertions + // Test list access - should be denied + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 2) - // Use FQN-based assertions - expectedResults := map[string]bool{ - testClassSecretFQN: false, // Secret doesn't allow create - testProjectAlphaFQN: true, // Project Alpha allows create - } - s.assertAllDecisionResults(decision, expectedResults) + // Test search access - should be denied + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionSearch, resources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) }) - // More complex cross-namespace scenarios - s.Run("Multiple resources from multiple namespaces", func() { - // Entity with full entitlements - entity := s.createEntityWithProps("cross-ns-user-4", map[string]interface{}{ - "clearance": "secret", - "project": "alpha", - "platform": "cloud", - }) - - // Multiple resources from different namespaces - resources := createResources( - testClassSecretFQN, + s.Run("Scenario 4: Conflicting action policies across multiple attributes", func() { + // Set up a PDP with the comprehensive actions public mapping and restricted mapping + restrictedMapping := createSimpleSubjectMapping( testClassConfidentialFQN, - testProjectAlphaFQN, - testPlatformCloudFQN, + "confidential", + []*policy.Action{testActionRead}, // Only read is allowed + ".properties.clearance", + []string{"restricted"}, ) - // Request for delete action - allowed only by platform cloud mapping - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + classificationPDP, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr}, + []*policy.SubjectMapping{allActionsPublicMapping, restrictedMapping}, + ) + s.Require().NoError(err) + s.Require().NotNil(classificationPDP) + + // Entity with both public and restricted clearance + entity := s.createEntityWithProps("admin001", map[string]interface{}{ + "clearance": "restricted", + }) + + // Resource with restricted classification + restrictedResources := createResourcePerFqn(testClassConfidentialFQN) + + // Test read access - should be allowed for restricted + decision, err := classificationPDP.GetDecision(s.T().Context(), entity, actionRead, restrictedResources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) - // Assertions + // Test create access - should be denied for restricted despite comprehensive actions on public + decision, err = classificationPDP.GetDecision(s.T().Context(), entity, actionCreate, restrictedResources) s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 4) - // Use FQN-based assertions - expectedResults := map[string]bool{ - testClassSecretFQN: false, // Secret doesn't allow delete - testClassConfidentialFQN: false, // Confidential doesn't allow delete - testProjectAlphaFQN: false, // Project Alpha doesn't allow delete - testPlatformCloudFQN: true, // Platform Cloud allows delete - } - s.assertAllDecisionResults(decision, expectedResults) - }) + // Test delete access - should be denied for restricted despite comprehensive actions on public + decision, err = classificationPDP.GetDecision(s.T().Context(), entity, testActionDelete, restrictedResources) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + }) +} - s.Run("Mixed namespace resources in a single resource", func() { - // Entity with full entitlements - entity := s.createEntityWithProps("cross-ns-user-5", map[string]interface{}{ - "clearance": "secret", - "project": "alpha", - "platform": "cloud", +// Test_GetDecision_CombinedAttributeRules tests scenarios with combinations of different attribute rules on a single resource +func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() { + + f := s.fixtures + + // Create a PDP with all attribute types (HIERARCHY, ANY_OF, ALL_OF) + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr, f.projectAttr, f.platformAttr}, + []*policy.SubjectMapping{ + f.topSecretMapping, f.secretMapping, f.confidentialMapping, f.publicMapping, + f.engineeringMapping, f.financeMapping, f.rndMapping, + f.usaMapping, f.ukMapping, f.projectAlphaMapping, f.platformCloudMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("HIERARCHY + ANY_OF combined: Secret classification and Engineering department", func() { + // Entity with proper entitlements for both attributes + entity := s.createEntityWithProps("hier-any-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", }) - // A single resource with FQNs from different namespaces - // Set a specific ID for this combined resource - combinedResource := &authz.Resource{ - EphemeralId: "combined-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassSecretFQN, testProjectAlphaFQN}, - }, - }, - } + // Single resource with both HIERARCHY (classification) and ANY_OF (department) attributes + combinedResource := createResource("secret-engineering-resource", testClassSecretFQN, testDeptEngineeringFQN) - // Request for read action + // Test read access (both allow) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - - // Assertions s.Require().NoError(err) s.Require().NotNil(decision) s.True(decision.Access) - // The implementation treats this as a single resource with multiple rules - s.Len(decision.Results, 1) - s.Equal("combined-resource", decision.Results[0].ResourceID) + // Test create access (only engineering allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass - // Instead of checking by FQN, confirm all data rule results pass - for _, dataRule := range decision.Results[0].DataRuleResults { - s.True(dataRule.Passed, "All data rules should pass") - s.Empty(dataRule.EntitlementFailures, "There should be no entitlement failures") - } + // Test update access (only secret allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass }) - // Additional complex scenarios from Test_GetDecision_ComplexNamespaceInteractions - s.Run("Entity with entitlements across three namespaces", func() { - // Entity with entitlements from all three namespaces - entity := s.createEntityWithProps("tri-namespace-entity", map[string]interface{}{ - "clearance": "secret", // from base namespace - "project": "alpha", // from secondary namespace - "platform": "hybrid", // from secondary namespace - "country": []any{"us"}, // ALL_OF rule - "department": "engineering", // ANY_OF rule + s.Run("HIERARCHY + ALL_OF combined: Secret classification and USA country", func() { + // Entity with proper entitlements for both attributes + entity := s.createEntityWithProps("hier-all-user-1", map[string]interface{}{ + "clearance": "secret", + "country": []any{"us", "uk"}, }) - // Resources from all namespaces - resources := createResources( - testClassSecretFQN, // base namespace - testDeptEngineeringFQN, // base namespace - testCountryUSAFQN, // base namespace - ALL_OF - testProjectAlphaFQN, // secondary namespace - testPlatformHybridFQN, // secondary namespace - ) + // Single resource with both HIERARCHY and ALL_OF attributes + combinedResource := createResource("secret-usa-resource", testClassSecretFQN, testCountryUSAFQN) - // Test read access - should pass for all namespaces - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + // Test read access (both allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + + // Test update access (only secret allows, usa doesn't) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass + }) + + s.Run("ANY_OF + ALL_OF combined: Engineering department and USA AND UK country", func() { + // Entity with proper entitlements for both attributes + entity := s.createEntityWithProps("any-all-user-1", map[string]interface{}{ + "department": "engineering", + "country": []any{"us", "uk"}, + }) + + // Single resource with both ANY_OF and ALL_OF attributes + combinedResource := createResource("engineering-usa-uk-resource", testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN) - // Assertions + // Test read access (both allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) + s.Require().NotNil(decision) s.True(decision.Access) - s.Len(decision.Results, 5) - decisionResults := map[string]bool{ - testClassSecretFQN: true, // Secret - testDeptEngineeringFQN: true, // Engineering - testCountryUSAFQN: true, // USA - testProjectAlphaFQN: true, // Project Alpha - testPlatformHybridFQN: true, // Platform Hybrid - } - s.assertAllDecisionResults(decision, decisionResults) + // Test create access (only engineering allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because both attributes need to pass + }) - // Test delete access - should only pass for hybrid platform - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ALL_OF FAILURE", func() { + // Entity with proper entitlements for all three attributes + entity := s.createEntityWithProps("all-rules-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, // does not have UK + }) - // Overall access should be denied + // Single resource with all three attribute rule types, but missing one ALL_OF value FQN + combinedResource := createResource("secret-engineering-usa-uk-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUKFQN, testCountryUSAFQN) + + // Test read access (all three allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) + s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 5) + s.Len(decision.Results, 1) - // Only hybrid platform allows delete - decisionResults = map[string]bool{ - testClassSecretFQN: false, // Secret - no delete - testDeptEngineeringFQN: false, // Engineering - no delete - testCountryUSAFQN: false, // USA - no delete - testProjectAlphaFQN: false, // Project Alpha - no delete - testPlatformHybridFQN: true, // Platform Hybrid - allows delete + // Drill down proper structure of denial + resourceDecision := decision.Results[0] + s.Require().False(resourceDecision.Passed) + s.Equal("secret-engineering-usa-uk-resource", resourceDecision.ResourceID) + s.Len(resourceDecision.DataRuleResults, 3) + for _, ruleResult := range resourceDecision.DataRuleResults { + switch ruleResult.RuleDefinition.GetFqn() { + case testClassificationFQN: + s.True(ruleResult.Passed) + case testDepartmentFQN: + s.True(ruleResult.Passed) + case testCountryFQN: + s.False(ruleResult.Passed) + } } - s.assertAllDecisionResults(decision, decisionResults) }) - s.Run("Resources from all namespaces in a single resource", func() { - // Entity with entitlements from all namespaces - entity := s.createEntityWithProps("multi-ns-entity", map[string]interface{}{ - "clearance": "secret", - "project": "beta", - "platform": "onprem", - "country": []any{"us"}, + s.Run("HIERARCHY + ANY_OF + ALL_OF combined - SUCCESS", func() { + // Entity with proper entitlements for all three attributes + entity := s.createEntityWithProps("all-rules-user-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, }) - // A single resource with attribute values from different namespaces - combinedResource := &authz.Resource{ - EphemeralId: "combined-multi-ns-resource", // Explicitly set a resource ID - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{ - testClassSecretFQN, // base namespace - testCountryUSAFQN, // base namespace - testProjectBetaFQN, // secondary namespace - testPlatformOnPremFQN, // secondary namespace - }, - }, - }, - } + // Single resource with all three attribute rule types + combinedResource := createResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) - // Test read access - should pass for this combined resource + // Test read access (all three allow) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - - // Assertions s.Require().NoError(err) + s.Require().NotNil(decision) s.True(decision.Access) - // The implementation treats this as a single resource with multiple rules - s.Len(decision.Results, 1) - s.Equal("combined-multi-ns-resource", decision.Results[0].ResourceID) - - // Instead of checking FQN by FQN, verify all data rules pass - s.Len(decision.Results[0].DataRuleResults, 4) // Should have 4 data rules (one for each FQN) - for _, dataRule := range decision.Results[0].DataRuleResults { - s.True(dataRule.Passed, "All data rules should pass for read action") - s.Empty(dataRule.EntitlementFailures, "There should be no entitlement failures for read action") + // No other action is permitted by all three attributes + for _, action := range []string{actions.ActionNameCreate, actions.ActionNameUpdate, actions.ActionNameDelete} { + d, err := pdp.GetDecision(s.T().Context(), entity, &policy.Action{Name: action}, []*authz.Resource{combinedResource}) + s.Require().NoError(err) + s.Require().NotNil(d) + s.False(d.Access, "Action %s should not be allowed", action) } + }) - // Test update access - should pass for all except country - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ANY_OF FAILURE", func() { + // Entity with only partial entitlements + entity := s.createEntityWithProps("partial-entitlement-user", map[string]interface{}{ + "clearance": "ts", + "department": "finance", // not matching engineering + "country": []any{"us"}, + }) - // Overall access should be denied due to country not supporting update + // Resource with all three attribute types + combinedResource := createResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) + + // Test read access - should fail because department doesn't match + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) + s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 1) - s.Equal("combined-multi-ns-resource", decision.Results[0].ResourceID) - // There should be 4 data rules, with some failing - s.Len(decision.Results[0].DataRuleResults, 4) + // Examine which attribute rule failed + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + s.Equal("secret-engineering-usa-resource", onlyDecision.ResourceID) - // Count passes and failures + // Count passes and failures among data rules passCount := 0 failCount := 0 - for _, dataRule := range decision.Results[0].DataRuleResults { + for _, dataRule := range onlyDecision.DataRuleResults { if dataRule.Passed { passCount++ - s.Empty(dataRule.EntitlementFailures) } else { failCount++ - s.NotEmpty(dataRule.EntitlementFailures) + // Check that failure is for country attribute + s.Contains(dataRule.RuleDefinition.GetFqn(), "department") } } - - // Expect 3 passes (Secret, Project Beta, Platform OnPrem) and 1 failure (Country USA) - s.Equal(3, passCount, "Should have 3 passing data rules for update action") - s.Equal(1, failCount, "Should have 1 failing data rule for update action") + s.Equal(2, passCount, "Two attributes should pass") + s.Equal(1, failCount, "One attribute should fail") }) -} -// TestGetEntitlements tests the functionality of retrieving entitlements for entities -func (s *PDPTestSuite) Test_GetEntitlements() { - f := s.fixtures + s.Run("Multiple attributes from different namespaces with different rules", func() { + // Entity with cross-namespace entitlements + entity := s.createEntityWithProps("cross-ns-rules-user", map[string]interface{}{ + "clearance": "secret", // HIERARCHY rule + "project": "alpha", // ANY_OF rule from secondary namespace + "platform": "cloud", // ANY_OF rule from secondary namespace + "country": []any{"us"}, // ALL_OF rule + }) - // Create a PDP with attributes and mappings - pdp, err := NewPolicyDecisionPoint( - s.T().Context(), - s.logger, - []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr}, - []*policy.SubjectMapping{ - f.secretMapping, f.confidentialMapping, f.publicMapping, - f.engineeringMapping, f.financeMapping, f.rndMapping, - f.usaMapping, - }, - ) - s.Require().NoError(err) - s.Require().NotNil(pdp) - - s.Run("Entity with multiple entitlements", func() { - // Entity with entitlements for secret clearance, engineering department, and USA country - entity := s.createEntityWithProps("test-entity-1", map[string]interface{}{ - "clearance": "secret", - "department": "engineering", - "country": []any{"us"}, - }) + // Resource with attributes from different namespaces and with different rules + complexResource := createResource("complex-multi-ns-resource", + testClassSecretFQN, // HIERARCHY rule + testCountryUSAFQN, // ALL_OF rule + testProjectAlphaFQN, // ANY_OF rule + testPlatformCloudFQN, // ANY_OF rule + ) - // Get entitlements for this entity - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + // Test read access (all four allow) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{complexResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) - // Assertions + // Test delete access (only platform:cloud allows) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{complexResource}) s.Require().NoError(err) - s.Require().NotNil(entitlements) + s.Require().NotNil(decision) + s.False(decision.Access) // Overall fails because other attributes don't allow delete - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, "test-entity-1") - s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + // Count how many attributes passed/failed for delete action + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + passCount := 0 + failCount := 0 + for _, dataRule := range onlyDecision.DataRuleResults { + if dataRule.Passed { + passCount++ + // Only the platform attribute should pass for delete + s.Contains(dataRule.RuleDefinition.GetFqn(), "platform") + } else { + failCount++ + } + } + s.Equal(1, passCount, "One attribute should pass (platform:cloud)") + s.Equal(3, failCount, "Three attributes should fail") + }) - // Verify entitlements for classification - secretActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassSecretFQN] - s.Require().NotNil(secretActions, "Secret classification entitlements should exist") - s.Contains(actionNames(secretActions.GetActions()), actions.ActionNameRead) - s.Contains(actionNames(secretActions.GetActions()), actions.ActionNameUpdate) + s.Run("Multiple HIERARCHY of duplicate same attribute value", func() { + // Create a resource with multiple classifications (hierarchy rule) + cascadingResource := &authz.Resource{ + EphemeralId: "classification-cascade-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{ + testClassSecretFQN, // Secret classification + testClassSecretFQN, // duplicate + testClassConfidentialFQN, // Confidential classification (lower than Secret) + testClassConfidentialFQN, // second duplicate + }, + }, + }, + } - // Verify entitlements for department - engineeringActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testDeptEngineeringFQN] - s.Require().NotNil(engineeringActions, "Engineering department entitlements should exist") - s.Contains(actionNames(engineeringActions.GetActions()), actions.ActionNameRead) - s.Contains(actionNames(engineeringActions.GetActions()), actions.ActionNameCreate) + // Entity with secret clearance (which should also give access to confidential) + entity := s.createEntityWithProps("secret-entity", map[string]interface{}{ + "clearance": "secret", + }) - // Verify entitlements for country - usaActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testCountryUSAFQN] - s.Require().NotNil(usaActions, "USA country entitlements should exist") - s.Contains(actionNames(usaActions.GetActions()), actions.ActionNameRead) - }) + // Test read access + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") - s.Run("Entity with no matching entitlements", func() { - // Entity with no entitlements based on properties - entity := s.createEntityWithProps("test-entity-2", map[string]interface{}{ - "clearance": "unknown", - "department": "unknown", - "country": []any{"unknown"}, + // Entity with confidential clearance (which should NOT give access to secret) + entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ + "clearance": "confidential", }) - // Get entitlements for this entity - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) - - // Assertions + // Test read access + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, "test-entity-2") - s.Require().NotNil(entityEntitlement, "Entity should be included in results even with no entitlements") - s.Empty(entityEntitlement.GetActionsPerAttributeValueFqn(), "No attribute value FQNs should be mapped for this entity") + // Verify which rule failed + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + s.Len(onlyDecision.DataRuleResults, 1) + ruleResult := onlyDecision.DataRuleResults[0] + s.NotEmpty(ruleResult.EntitlementFailures) + s.Equal(ruleResult.EntitlementFailures[0].AttributeValueFQN, testClassSecretFQN) }) - s.Run("Entity with partial entitlements", func() { - // Entity with some entitlements - entity := s.createEntityWithProps("test-entity-3", map[string]interface{}{ - "clearance": "public", - "department": "sales", // No mapping for sales - }) + s.Run("Multiple HIERARCHY of different levels", func() { + // Create a resource with multiple classifications (hierarchy rule) + cascadingResource := createResource("classification-cascade-resource", + testClassSecretFQN, // Secret classification + testClassConfidentialFQN, // Confidential classification (lower than Secret) + ) - // Get entitlements for this entity - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + // Entity with topsecret clearance (which should also give access to confidential) + entity := s.createEntityWithProps("secret-entity", map[string]interface{}{ + "clearance": "ts", + }) - // Assertions + // Test read access + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, "test-entity-3") - s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + // Entity with confidential clearance (which should NOT give access to secret) + entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ + "clearance": "confidential", + }) - // Verify public classification entitlements exist - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassPublicFQN, "Public classification entitlements should exist") - publicActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassPublicFQN] - s.Contains(actionNames(publicActions.GetActions()), actions.ActionNameRead) + // Test read access + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") - // Verify sales department entitlements do not exist - s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testDeptSalesFQN, "Sales department should not have entitlements") + // Verify which rule failed + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + s.Len(onlyDecision.DataRuleResults, 1) + ruleResult := onlyDecision.DataRuleResults[0] + s.Len(ruleResult.EntitlementFailures, 1) + s.Equal(ruleResult.EntitlementFailures[0].AttributeValueFQN, testClassSecretFQN) }) +} - s.Run("Multiple entities with various entitlements", func() { - entityCases := []struct { - name string - entityRepresentation *entityresolutionV2.EntityRepresentation - expectedEntitlements []string - }{ - { - name: "admin-entity", - entityRepresentation: f.adminEntity, - expectedEntitlements: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, - }, - { - name: "developer-entity", - entityRepresentation: f.developerEntity, - expectedEntitlements: []string{testClassConfidentialFQN, testDeptEngineeringFQN, testCountryUSAFQN}, - }, - { - name: "analyst-entity", - entityRepresentation: f.analystEntity, - expectedEntitlements: []string{testClassConfidentialFQN, testDeptFinanceFQN}, - }, - } +// Test_GetDecision_AcrossNamespaces tests cross-namespace decisions with various scenarios +func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { + f := s.fixtures - for _, entityCase := range entityCases { - s.Run(entityCase.name, func() { - // Get entitlements for this entity - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entityCase.entityRepresentation}, nil, false) + // Create mappings for additional secondary namespace values + betaMapping := createSimpleSubjectMapping( + testProjectBetaFQN, + "beta", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.project", + []string{"beta"}, + ) - // Assertions - s.Require().NoError(err) + gammaMapping := createSimpleSubjectMapping( + testProjectGammaFQN, + "gamma", + []*policy.Action{testActionRead, testActionCreate, testActionDelete}, + ".properties.project", + []string{"gamma"}, + ) - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, entityCase.name) - s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") - s.Require().Len(entityEntitlement.GetActionsPerAttributeValueFqn(), len(entityCase.expectedEntitlements), "Number of entitlements should match expected") + onPremMapping := createSimpleSubjectMapping( + testPlatformOnPremFQN, + "onprem", + []*policy.Action{testActionRead, testActionUpdate}, + ".properties.platform", + []string{"onprem"}, + ) - // Verify expected entitlements exist - for _, expectedFQN := range entityCase.expectedEntitlements { - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), expectedFQN) - } - }) - } - }) + hybridMapping := createSimpleSubjectMapping( + testPlatformHybridFQN, + "hybrid", + []*policy.Action{testActionRead, testActionCreate, testActionUpdate, testActionDelete}, + ".properties.platform", + []string{"hybrid"}, + ) - s.Run("With comprehensive hierarchy", func() { - // Entity with secret clearance - entity := s.createEntityWithProps("hierarchy-test-entity", map[string]interface{}{ + // Create a PDP with attributes and mappings from all namespaces + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr, f.projectAttr, f.platformAttr}, + []*policy.SubjectMapping{ + f.topSecretMapping, f.secretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping, + f.projectAlphaMapping, betaMapping, gammaMapping, + f.platformCloudMapping, onPremMapping, hybridMapping, + f.usaMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("Cross-namespace decision - full access", func() { + entity := s.createEntityWithProps("cross-ns-user-1", map[string]interface{}{ "clearance": "secret", + "project": "alpha", }) - // Get entitlements with comprehensive hierarchy - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, true) + // Two resources with each a different namespaced attribute value + resources := createResourcePerFqn(testClassSecretFQN, testProjectAlphaFQN) + + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) - // Assertions s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 2) - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, "hierarchy-test-entity") - s.Require().NotNil(entityEntitlement) + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: true, + testProjectAlphaFQN: true, + } + s.assertAllDecisionResults(decision, expectedResults) + }) - // With comprehensive hierarchy, the entity should have access to secret and all lower classifications - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassSecretFQN) - - // The function populateLowerValuesIfHierarchy assumes the values in the hierarchy are arranged - // in order from highest to lowest. In our test fixture, that means: - // topsecret > secret > confidential > public + s.Run("Cross-namespace decision - partial access", func() { + // Entity with partial entitlements + entity := s.createEntityWithProps("cross-ns-user-2", map[string]interface{}{ + "clearance": "secret", + "project": "beta", // Not alpha + "platform": "cloud", + }) - // Secret clearance should give access to confidential and public (the items lower in the list) - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassConfidentialFQN) - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassPublicFQN) + // Resource with attribute values from two different namespaces + resource := createResource("secret-alpha-cloud-fqn", testClassSecretFQN, testProjectAlphaFQN, testPlatformCloudFQN) - // But not to higher classifications - s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassTopSecretFQN) + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) - // Verify the actions for the lower levels match those granted to the secret level - secretActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassSecretFQN] - s.Require().NotNil(secretActions) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + s.Len(onlyDecision.DataRuleResults, 3) + for _, dataRule := range onlyDecision.DataRuleResults { + if dataRule.Passed { + isExpected := dataRule.RuleDefinition.GetFqn() == testPlatformFQN || dataRule.RuleDefinition.GetFqn() == testClassificationFQN + s.True(isExpected, "Platform and classification should pass") + } else { + s.Equal(testProjectFQN, dataRule.RuleDefinition.GetFqn(), "Project should fail") + s.Len(dataRule.EntitlementFailures, 1) + s.Equal(testProjectAlphaFQN, dataRule.EntitlementFailures[0].AttributeValueFQN) + } + } + }) - confidentialActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassConfidentialFQN] - s.Require().NotNil(confidentialActions) + s.Run("Action permitted by one namespace mapping but not the other", func() { + // Entity with entitlements for both namespaces + entity := s.createEntityWithProps("cross-ns-user-3", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", + }) - publicActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassPublicFQN] - s.Require().NotNil(publicActions) + resources := createResourcePerFqn(testClassSecretFQN, testProjectAlphaFQN) - s.Len(secretActions.GetActions(), len(f.secretMapping.GetActions())) + // Create action is permitted for project alpha but not for secret + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionCreate, resources) - // The actions should be the same for all levels - s.ElementsMatch( - actionNames(secretActions.GetActions()), - actionNames(confidentialActions.GetActions()), - "Secret and confidential should have the same actions") + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 2) - s.ElementsMatch( - actionNames(secretActions.GetActions()), - actionNames(publicActions.GetActions()), - "Secret and public should have the same actions") + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: false, // Secret doesn't allow create + testProjectAlphaFQN: true, // Project Alpha allows create + } + s.assertAllDecisionResults(decision, expectedResults) }) - s.Run("With filtered subject mappings", func() { - // Entity with multiple entitlements - entity := s.createEntityWithProps("filtered-test-entity", map[string]interface{}{ - "clearance": "secret", - "department": "engineering", - "country": []any{"us"}, + // More complex cross-namespace scenarios + s.Run("Multiple resources from multiple namespaces", func() { + // Entity with full entitlements + entity := s.createEntityWithProps("cross-ns-user-4", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", }) - // Filter to only classification mappings - filteredMappings := []*policy.SubjectMapping{f.secretMapping, f.confidentialMapping, f.publicMapping} + // Multiple resources from different namespaces + resources := createResourcePerFqn( + testClassSecretFQN, + testClassConfidentialFQN, + testProjectAlphaFQN, + testPlatformCloudFQN, + ) - // Get entitlements with filtered mappings - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, filteredMappings, false) + // Request for delete action - allowed only by platform cloud mapping + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) - // Assertions s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 4) - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, "filtered-test-entity") - s.Require().NotNil(entityEntitlement) + // Use FQN-based assertions + expectedResults := map[string]bool{ + testClassSecretFQN: false, // Secret doesn't allow delete + testClassConfidentialFQN: false, // Confidential doesn't allow delete + testProjectAlphaFQN: false, // Project Alpha doesn't allow delete + testPlatformCloudFQN: true, // Platform Cloud allows delete + } + s.assertAllDecisionResults(decision, expectedResults) + }) - // Should only have classification entitlements - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassSecretFQN) + s.Run("Mixed namespace resources in a single resource", func() { + // Entity with full entitlements + entity := s.createEntityWithProps("cross-ns-user-5", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + "platform": "cloud", + }) - // Should not have department or country entitlements - s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testDeptEngineeringFQN) - s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testCountryUSAFQN) - }) -} + // A single resource with FQNs from different namespaces + // Set a specific ID for this combined resource + combinedResource := &authz.Resource{ + EphemeralId: "combined-resource", + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{testClassSecretFQN, testProjectAlphaFQN}, + }, + }, + } -func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { - testAdvancedHierarchyNs := "advanced.hier" - hierarchyAttrName := "hierarchy_attr" - actionNameTransmit := "custom_transmit" - customActionGather := "gather" + // Request for read action + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - hierarchyTestAttrName := createAttrFQN(testAdvancedHierarchyNs, hierarchyAttrName) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) - topValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "top") - upperMiddleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "upper-middle") - middleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "middle") - lowerMiddleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "lower-middle") - bottomValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "bottom") + // The implementation treats this as a single resource with multiple rules + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + s.Equal("combined-resource", onlyDecision.ResourceID) - hierarchyAttribute := &policy.Attribute{ - Fqn: hierarchyTestAttrName, - Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, - Values: []*policy.Value{ - { - Fqn: topValueFQN, - Value: "top", - }, - { - Fqn: upperMiddleValueFQN, - Value: "upper-middle", - }, - { - Fqn: middleValueFQN, - Value: "middle", - }, - { - Fqn: lowerMiddleValueFQN, - Value: "lower-middle", - }, - { - Fqn: bottomValueFQN, - Value: "bottom", - }, - }, - } + // Instead of checking by FQN, confirm all data rule results pass + for _, dataRule := range onlyDecision.DataRuleResults { + s.True(dataRule.Passed, "All data rules should pass") + s.Empty(dataRule.EntitlementFailures, "There should be no entitlement failures") + } + }) - topMapping := createSimpleSubjectMapping( - topValueFQN, - "top", - []*policy.Action{testActionRead}, - ".properties.levels[]", - []string{"top"}, - ) - upperMiddleMapping := createSimpleSubjectMapping( - upperMiddleValueFQN, - "upper-middle", - []*policy.Action{testActionCreate}, - ".properties.levels[]", - []string{"upper-middle"}, - ) - middleMapping := createSimpleSubjectMapping( - middleValueFQN, - "middle", - []*policy.Action{testActionUpdate, {Name: actionNameTransmit}}, - ".properties.levels[]", - []string{"middle"}, - ) - lowerMiddleMapping := createSimpleSubjectMapping( - lowerMiddleValueFQN, - "lower-middle", - []*policy.Action{testActionDelete}, - ".properties.levels[]", - []string{"lower-middle"}, - ) - bottomMapping := createSimpleSubjectMapping( - bottomValueFQN, - "bottom", - []*policy.Action{{Name: customActionGather}}, - ".properties.levels[]", - []string{"bottom"}, - ) + // Additional complex scenarios from Test_GetDecision_ComplexNamespaceInteractions + s.Run("Entity with entitlements across three namespaces", func() { + // Entity with entitlements from all three namespaces + entity := s.createEntityWithProps("tri-namespace-entity", map[string]interface{}{ + "clearance": "secret", // from base namespace + "project": "alpha", // from secondary namespace + "platform": "hybrid", // from secondary namespace + "country": []any{"us"}, // ALL_OF rule + "department": "engineering", // ANY_OF rule + }) - // Create a PDP with the hierarchy attribute and mappings - pdp, err := NewPolicyDecisionPoint( - s.T().Context(), - s.logger, - []*policy.Attribute{hierarchyAttribute}, - []*policy.SubjectMapping{ - topMapping, - upperMiddleMapping, - middleMapping, - lowerMiddleMapping, - bottomMapping, - }, - ) - s.Require().NoError(err) - s.Require().NotNil(pdp) + // Resources from all namespaces + resources := createResourcePerFqn( + testClassSecretFQN, // base namespace + testDeptEngineeringFQN, // base namespace + testCountryUSAFQN, // base namespace - ALL_OF + testProjectAlphaFQN, // secondary namespace + testPlatformHybridFQN, // secondary namespace + ) - // Create an entity with every level in the hierarchy - entity := s.createEntityWithProps("hierarchy-test-entity", map[string]interface{}{ - "levels": []any{"top", "upper-middle", "middle", "lower-middle", "bottom"}, - }) + // Test read access - should pass for all namespaces + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) - // Get entitlements for this entity - withComprehensiveHierarchy := true - entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, withComprehensiveHierarchy) - s.Require().NoError(err) - s.Require().NotNil(entitlements) + s.Require().NoError(err) + s.True(decision.Access) + s.Len(decision.Results, 5) - // Find the entity's entitlements - entityEntitlement := findEntityEntitlements(entitlements, "hierarchy-test-entity") - s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") - s.Require().Len(entityEntitlement.GetActionsPerAttributeValueFqn(), 5, "Number of entitlements should match expected") - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), topValueFQN, "Top level should be present") - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), upperMiddleValueFQN, "Upper-middle level should be present") - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), middleValueFQN, "Middle level should be present") - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), lowerMiddleValueFQN, "Lower-middle level should be present") - s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), bottomValueFQN, "Bottom level should be present") + decisionResults := map[string]bool{ + testClassSecretFQN: true, // Secret + testDeptEngineeringFQN: true, // Engineering + testCountryUSAFQN: true, // USA + testProjectAlphaFQN: true, // Project Alpha + testPlatformHybridFQN: true, // Platform Hybrid + } + s.assertAllDecisionResults(decision, decisionResults) - // Verify actions for each level - topActions := entityEntitlement.GetActionsPerAttributeValueFqn()[topValueFQN] - s.Require().NotNil(topActions, "Top level actions should exist") - s.Len(topActions.GetActions(), 1) - s.Contains(actionNames(topActions.GetActions()), actions.ActionNameRead, "Top level should have read action") + // Test delete access - should only pass for hybrid platform + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) - upperMiddleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[upperMiddleValueFQN] - s.Require().NotNil(upperMiddleActions, "Upper-middle level actions should exist") - s.Len(upperMiddleActions.GetActions(), 2) - upperMiddleActionNames := actionNames(upperMiddleActions.GetActions()) - s.Contains(upperMiddleActionNames, actions.ActionNameCreate, "Upper-middle level should have create action") - s.Contains(upperMiddleActionNames, actions.ActionNameRead, "Upper-middle level should have read action") + // Overall access should be denied + s.Require().NoError(err) + s.False(decision.Access) + s.Len(decision.Results, 5) - middleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[middleValueFQN] - s.Require().NotNil(middleActions, "Middle level actions should exist") - s.Len(middleActions.GetActions(), 4) - middleActionNames := actionNames(middleActions.GetActions()) - s.Contains(middleActionNames, actions.ActionNameUpdate, "Middle level should have update action") - s.Contains(middleActionNames, actionNameTransmit, "Middle level should have transmit action") - s.Contains(middleActionNames, actions.ActionNameCreate, "Middle level should have create action") - s.Contains(middleActionNames, actions.ActionNameRead, "Middle level should have read action") + // Only hybrid platform allows delete + decisionResults = map[string]bool{ + testClassSecretFQN: false, // Secret - no delete + testDeptEngineeringFQN: false, // Engineering - no delete + testCountryUSAFQN: false, // USA - no delete + testProjectAlphaFQN: false, // Project Alpha - no delete + testPlatformHybridFQN: true, // Platform Hybrid - allows delete + } + s.assertAllDecisionResults(decision, decisionResults) + }) - lowerMiddleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[lowerMiddleValueFQN] - s.Require().NotNil(lowerMiddleActions, "Lower-middle level actions should exist") - s.Len(lowerMiddleActions.GetActions(), 5) - lowerMiddleActionNames := actionNames(lowerMiddleActions.GetActions()) - s.Contains(lowerMiddleActionNames, actions.ActionNameDelete, "Lower-middle level should have delete action") - s.Contains(lowerMiddleActionNames, actions.ActionNameUpdate, "Lower-middle level should have update action") - s.Contains(lowerMiddleActionNames, actions.ActionNameCreate, "Lower-middle level should have create action") - s.Contains(lowerMiddleActionNames, actionNameTransmit, "Lower-middle level should have read action") - s.Contains(lowerMiddleActionNames, actions.ActionNameRead, "Lower-middle level should have read action") + s.Run("Resources from all namespaces in a single resource", func() { + // Entity with entitlements from all namespaces + entity := s.createEntityWithProps("multi-ns-entity", map[string]interface{}{ + "clearance": "secret", + "project": "beta", + "platform": "onprem", + "country": []any{"us"}, + }) - bottomActions := entityEntitlement.GetActionsPerAttributeValueFqn()[bottomValueFQN] - s.Require().NotNil(bottomActions, "Bottom level actions should exist") - s.Len(bottomActions.GetActions(), 6) - bottomActionNames := actionNames(bottomActions.GetActions()) - s.Contains(bottomActionNames, actions.ActionNameRead, "Bottom level should have read action") - s.Contains(bottomActionNames, actions.ActionNameUpdate, "Bottom level should have update action") - s.Contains(bottomActionNames, actions.ActionNameCreate, "Bottom level should have create action") - s.Contains(bottomActionNames, actions.ActionNameDelete, "Bottom level should have delete action") - s.Contains(bottomActionNames, actionNameTransmit, "Bottom level should have transmit action") - s.Contains(bottomActionNames, customActionGather, "Bottom level should have gather action") -} + // A single resource with attribute values from different namespaces + combinedResource := createResource("combined-multi-ns-resource", + testClassConfidentialFQN, // base namespace + testCountryUSAFQN, // base namespace + testProjectBetaFQN, // secondary namespace + testPlatformOnPremFQN, // secondary namespace + ) -// Test_GetDecision_PartialActionEntitlement tests scenarios where actions only partially align with entitlements -func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { - f := s.fixtures + // Test read access - should pass for this combined resource + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - // Define a custom print action for testing - testActionPrint := &policy.Action{Name: "print"} + s.Require().NoError(err) + s.True(decision.Access) - // Define a custom view action that is a parent of read and list - testActionView := &policy.Action{Name: "view"} - testActionList := &policy.Action{Name: "list"} - testActionSearch := &policy.Action{Name: "search"} + // The implementation treats this as a single resource with multiple rules + s.Len(decision.Results, 1) + onlyDecision := decision.Results[0] + s.Equal("combined-multi-ns-resource", onlyDecision.ResourceID) - // Create additional mappings for testing partial action scenarios - printConfidentialMapping := createSimpleSubjectMapping( - testClassConfidentialFQN, - "confidential", - []*policy.Action{testActionRead, testActionPrint}, - ".properties.clearance", - []string{"confidential"}, - ) + // Instead of checking FQN by FQN, verify all data rules pass + s.Len(onlyDecision.DataRuleResults, 4) // Should have 4 data rules (one for each FQN) + for _, dataRule := range onlyDecision.DataRuleResults { + s.True(dataRule.Passed, "All data rules should pass for read action") + s.Empty(dataRule.EntitlementFailures, "There should be no entitlement failures for read action") + } - // Create a mapping with a comprehensive set of actions instead of using a wildcard - allActionsPublicMapping := createSimpleSubjectMapping( - testClassPublicFQN, - "public", - []*policy.Action{ - testActionRead, testActionCreate, testActionUpdate, testActionDelete, - testActionPrint, testActionView, testActionList, testActionSearch, - }, - ".properties.clearance", - []string{"public"}, - ) + // Test update access - should pass for all except country + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) - // Create a view mapping for Project Alpha with view being a parent action of read and list - viewProjectAlphaMapping := createSimpleSubjectMapping( - testProjectAlphaFQN, - "alpha", - []*policy.Action{testActionView}, - ".properties.project", - []string{"alpha"}, - ) + // Overall access should be denied due to country not supporting update + s.Require().NoError(err) + s.False(decision.Access) + s.Len(decision.Results, 1) + onlyDecision = decision.Results[0] + s.Equal("combined-multi-ns-resource", onlyDecision.ResourceID) - // Create a PDP with relevant attributes and mappings + // There should be 4 data rules, with some failing + s.Len(onlyDecision.DataRuleResults, 4) + + // Count passes and failures + passCount := 0 + failCount := 0 + for _, dataRule := range onlyDecision.DataRuleResults { + if dataRule.Passed { + passCount++ + s.Empty(dataRule.EntitlementFailures) + } else { + failCount++ + s.NotEmpty(dataRule.EntitlementFailures) + } + } + + // Expect 3 passes (Secret, Project Beta, Platform OnPrem) and 1 failure (Country USA) + s.Equal(3, passCount, "Should have 3 passing data rules for update action") + s.Equal(1, failCount, "Should have 1 failing data rule for update action") + }) +} + +// TestGetEntitlements tests the functionality of retrieving entitlements for entities +func (s *PDPTestSuite) Test_GetEntitlements() { + + f := s.fixtures + + // Create a PDP with attributes and mappings pdp, err := NewPolicyDecisionPoint( s.T().Context(), s.logger, - []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.projectAttr}, + []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr}, []*policy.SubjectMapping{ - f.secretMapping, printConfidentialMapping, allActionsPublicMapping, - f.engineeringMapping, f.financeMapping, viewProjectAlphaMapping, + f.secretMapping, f.confidentialMapping, f.publicMapping, + f.engineeringMapping, f.financeMapping, f.rndMapping, + f.usaMapping, }, ) s.Require().NoError(err) s.Require().NotNil(pdp) - s.Run("Scenario 1: User has subset of requested actions", func() { - // Entity with secret clearance - only entitled to read and update on secret - entity := s.createEntityWithProps("user123", map[string]interface{}{ - "clearance": "secret", + s.Run("Entity with multiple entitlements", func() { + // Entity with entitlements for secret clearance, engineering department, and USA country + entity := s.createEntityWithProps("test-entity-1", map[string]interface{}{ + "clearance": "secret", + "department": "engineering", + "country": []any{"us"}, }) - // Resource to evaluate - resources := createResources(testClassSecretFQN) - - decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, resources) + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) - // Read shuld pass s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) // Should be true because read is allowed - s.Len(decision.Results, 1) + s.Require().NotNil(entitlements) - // Create should fail - decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // Should be false because create is not allowed - s.Len(decision.Results, 1) + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "test-entity-1") + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + + // Verify entitlements for classification + secretActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassSecretFQN] + s.Require().NotNil(secretActions, "Secret classification entitlements should exist") + s.Contains(actionNames(secretActions.GetActions()), actions.ActionNameRead) + s.Contains(actionNames(secretActions.GetActions()), actions.ActionNameUpdate) + + // Verify entitlements for department + engineeringActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testDeptEngineeringFQN] + s.Require().NotNil(engineeringActions, "Engineering department entitlements should exist") + s.Contains(actionNames(engineeringActions.GetActions()), actions.ActionNameRead) + s.Contains(actionNames(engineeringActions.GetActions()), actions.ActionNameCreate) + + // Verify entitlements for country + usaActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testCountryUSAFQN] + s.Require().NotNil(usaActions, "USA country entitlements should exist") + s.Contains(actionNames(usaActions.GetActions()), actions.ActionNameRead) }) - s.Run("Scenario 2: User has overlapping action sets", func() { - // Entity with both confidential clearance and finance department - entity := s.createEntityWithProps("user456", map[string]interface{}{ - "clearance": "confidential", - "department": "finance", + s.Run("Entity with no matching entitlements", func() { + // Entity with no entitlements based on properties + entity := s.createEntityWithProps("test-entity-2", map[string]interface{}{ + "clearance": "unknown", + "department": "unknown", + "country": []any{"unknown"}, }) - // Create a resource with both confidential and finance attributes - combinedResource := &authz.Resource{ - EphemeralId: "combined-attr-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassConfidentialFQN, testDeptFinanceFQN}, - }, - }, - } + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) - // Test read access - should be allowed by both attributes - decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) - s.Len(decision.Results, 1) - // Test create access - should be denied (confidential doesn't allow it) - decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // Overall access is denied + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "test-entity-2") + s.Require().NotNil(entityEntitlement, "Entity should be included in results even with no entitlements") + s.Empty(entityEntitlement.GetActionsPerAttributeValueFqn(), "No attribute value FQNs should be mapped for this entity") + }) - // Test print access - allowed by confidential but not by finance - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionPrint, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // Overall access is denied because one rule fails + s.Run("Entity with partial entitlements", func() { + // Entity with some entitlements + entity := s.createEntityWithProps("test-entity-3", map[string]interface{}{ + "clearance": "public", + "department": "sales", // No mapping for sales + }) - // Test update access - allowed by finance but not by confidential - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // Overall access is denied because one rule fails + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, false) - // Test delete access - denied by both - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{combinedResource}) s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) - }) - - s.Run("Scenario 3: Action inheritance with partial permissions", func() { - // Entity with project alpha access - entity := s.createEntityWithProps("user789", map[string]interface{}{ - "project": "alpha", - }) - // Resource with project alpha attribute - resources := createResources(testProjectAlphaFQN) - - // Test view access - should be allowed - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionView, resources) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "test-entity-3") + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") - // Test list access - should be denied - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) + // Verify public classification entitlements exist + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassPublicFQN, "Public classification entitlements should exist") + publicActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassPublicFQN] + s.Contains(actionNames(publicActions.GetActions()), actions.ActionNameRead) - // Test search access - should be denied - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionSearch, resources) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) + // Verify sales department entitlements do not exist + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testDeptSalesFQN, "Sales department should not have entitlements") }) - s.Run("Scenario 4: Conflicting action policies across multiple attributes", func() { - // Set up a PDP with the comprehensive actions public mapping and restricted mapping - restrictedMapping := createSimpleSubjectMapping( - testClassConfidentialFQN, - "confidential", - []*policy.Action{testActionRead}, // Only read is allowed - ".properties.clearance", - []string{"restricted"}, - ) + s.Run("Multiple entities with various entitlements", func() { + entityCases := []struct { + name string + entityRepresentation *entityresolutionV2.EntityRepresentation + expectedEntitlements []string + }{ + { + name: "admin-entity", + entityRepresentation: f.adminEntity, + expectedEntitlements: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, + }, + { + name: "developer-entity", + entityRepresentation: f.developerEntity, + expectedEntitlements: []string{testClassConfidentialFQN, testDeptEngineeringFQN, testCountryUSAFQN}, + }, + { + name: "analyst-entity", + entityRepresentation: f.analystEntity, + expectedEntitlements: []string{testClassConfidentialFQN, testDeptFinanceFQN}, + }, + } - classificationPDP, err := NewPolicyDecisionPoint( - s.T().Context(), - s.logger, - []*policy.Attribute{f.classificationAttr}, - []*policy.SubjectMapping{allActionsPublicMapping, restrictedMapping}, - ) - s.Require().NoError(err) - s.Require().NotNil(classificationPDP) + for _, entityCase := range entityCases { + s.Run(entityCase.name, func() { + // Get entitlements for this entity + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entityCase.entityRepresentation}, nil, false) - // Entity with both public and restricted clearance - entity := s.createEntityWithProps("admin001", map[string]interface{}{ - "clearance": "restricted", - }) + s.Require().NoError(err) - // Resource with restricted classification - restrictedResources := createResources(testClassConfidentialFQN) + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, entityCase.name) + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + s.Require().Len(entityEntitlement.GetActionsPerAttributeValueFqn(), len(entityCase.expectedEntitlements), "Number of entitlements should match expected") - // Test read access - should be allowed for restricted - decision, err := classificationPDP.GetDecision(s.T().Context(), entity, actionRead, restrictedResources) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) + // Verify expected entitlements exist + for _, expectedFQN := range entityCase.expectedEntitlements { + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), expectedFQN) + } + }) + } + }) - // Test create access - should be denied for restricted despite comprehensive actions on public - decision, err = classificationPDP.GetDecision(s.T().Context(), entity, actionCreate, restrictedResources) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) + s.Run("With comprehensive hierarchy", func() { + // Entity with secret clearance + entity := s.createEntityWithProps("hierarchy-test-entity", map[string]interface{}{ + "clearance": "secret", + }) + + // Get entitlements with comprehensive hierarchy + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, true) - // Test delete access - should be denied for restricted despite comprehensive actions on public - decision, err = classificationPDP.GetDecision(s.T().Context(), entity, testActionDelete, restrictedResources) s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) - }) -} -// Test_GetDecision_CombinedAttributeRules tests scenarios with combinations of different attribute rules on a single resource -func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() { - f := s.fixtures + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "hierarchy-test-entity") + s.Require().NotNil(entityEntitlement) - // Create a PDP with all attribute types (HIERARCHY, ANY_OF, ALL_OF) - pdp, err := NewPolicyDecisionPoint( - s.T().Context(), - s.logger, - []*policy.Attribute{f.classificationAttr, f.departmentAttr, f.countryAttr, f.projectAttr, f.platformAttr}, - []*policy.SubjectMapping{ - f.secretMapping, f.confidentialMapping, f.publicMapping, - f.engineeringMapping, f.financeMapping, f.rndMapping, - f.usaMapping, f.ukMapping, f.projectAlphaMapping, f.platformCloudMapping, - }, - ) - s.Require().NoError(err) - s.Require().NotNil(pdp) + // With comprehensive hierarchy, the entity should have access to secret and all lower classifications + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassSecretFQN) - s.Run("HIERARCHY + ANY_OF combined: Secret classification and Engineering department", func() { - // Entity with proper entitlements for both attributes - entity := s.createEntityWithProps("hier-any-user-1", map[string]interface{}{ - "clearance": "secret", - "department": "engineering", - }) + // The function populateLowerValuesIfHierarchy assumes the values in the hierarchy are arranged + // in order from highest to lowest. In our test fixture, that means: + // topsecret > secret > confidential > public - // Single resource with both HIERARCHY (classification) and ANY_OF (department) attributes - combinedResource := &authz.Resource{ - EphemeralId: "secret-engineering-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN}, - }, - }, - } + // Secret clearance should give access to confidential and public (the items lower in the list) + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassConfidentialFQN) + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassPublicFQN) - // Test read access (both allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) + // But not to higher classifications + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassTopSecretFQN) - // Test create access (only engineering allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass + // Verify the actions for the lower levels match those granted to the secret level + secretActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassSecretFQN] + s.Require().NotNil(secretActions) - // Test update access (only secret allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass - }) + confidentialActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassConfidentialFQN] + s.Require().NotNil(confidentialActions) - s.Run("HIERARCHY + ALL_OF combined: Secret classification and USA country", func() { - // Entity with proper entitlements for both attributes - entity := s.createEntityWithProps("hier-all-user-1", map[string]interface{}{ - "clearance": "secret", - "country": []any{"us", "uk"}, - }) + publicActions := entityEntitlement.GetActionsPerAttributeValueFqn()[testClassPublicFQN] + s.Require().NotNil(publicActions) - // Single resource with both HIERARCHY and ALL_OF attributes - combinedResource := &authz.Resource{ - EphemeralId: "secret-usa-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassSecretFQN, testCountryUSAFQN}, - }, - }, - } + s.Len(secretActions.GetActions(), len(f.secretMapping.GetActions())) - // Test read access (both allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) + // The actions should be the same for all levels + s.ElementsMatch( + actionNames(secretActions.GetActions()), + actionNames(confidentialActions.GetActions()), + "Secret and confidential should have the same actions") - // Test update access (only secret allows, usa doesn't) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass + s.ElementsMatch( + actionNames(secretActions.GetActions()), + actionNames(publicActions.GetActions()), + "Secret and public should have the same actions") }) - s.Run("ANY_OF + ALL_OF combined: Engineering department and USA AND UK country", func() { - // Entity with proper entitlements for both attributes - entity := s.createEntityWithProps("any-all-user-1", map[string]interface{}{ + s.Run("With filtered subject mappings", func() { + // Entity with multiple entitlements + entity := s.createEntityWithProps("filtered-test-entity", map[string]interface{}{ + "clearance": "secret", "department": "engineering", - "country": []any{"us", "uk"}, + "country": []any{"us"}, }) - // Single resource with both ANY_OF and ALL_OF attributes - combinedResource := &authz.Resource{ - EphemeralId: "engineering-usa-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN}, - }, - }, - } + // Filter to only classification mappings + filteredMappings := []*policy.SubjectMapping{f.secretMapping, f.confidentialMapping, f.publicMapping} - // Test read access (both allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) + // Get entitlements with filtered mappings + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, filteredMappings, false) - // Test create access (only engineering allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass - }) - s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ALL_OF FAILURE", func() { - // Entity with proper entitlements for all three attributes - entity := s.createEntityWithProps("all-rules-user-1", map[string]interface{}{ - "clearance": "secret", - "department": "engineering", - "country": []any{"us"}, // does not have UK - }) - - // Single resource with all three attribute rule types, but missing one ALL_OF value FQN - combinedResource := &authz.Resource{ - EphemeralId: "secret-engineering-usa-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN}, - }, - }, - } + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "filtered-test-entity") + s.Require().NotNil(entityEntitlement) - // Test read access (all three allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) - s.Len(decision.Results, 1) + // Should only have classification entitlements + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), testClassSecretFQN) - // Drill down proper structure of denial - resourceDecision := decision.Results[0] - s.Require().False(resourceDecision.Passed) - s.Equal("secret-engineering-usa-resource", resourceDecision.ResourceID) - s.Len(resourceDecision.DataRuleResults, 3) - for _, ruleResult := range resourceDecision.DataRuleResults { - switch ruleResult.RuleDefinition.GetFqn() { - case testClassificationFQN: - s.True(ruleResult.Passed) - case testDepartmentFQN: - s.True(ruleResult.Passed) - case testCountryFQN: - s.False(ruleResult.Passed) - } - } + // Should not have department or country entitlements + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testDeptEngineeringFQN) + s.NotContains(entityEntitlement.GetActionsPerAttributeValueFqn(), testCountryUSAFQN) }) +} - s.Run("HIERARCHY + ANY_OF + ALL_OF combined - SUCCESS", func() { - // Entity with proper entitlements for all three attributes - entity := s.createEntityWithProps("all-rules-user-1", map[string]interface{}{ - "clearance": "secret", - "department": "engineering", - "country": []any{"us"}, - }) - - // Single resource with all three attribute rule types - combinedResource := &authz.Resource{ - EphemeralId: "secret-engineering-usa-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, - }, - }, - } - - // Test read access (all three allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) +func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { + testAdvancedHierarchyNs := "advanced.hier" + hierarchyAttrName := "hierarchy_attr" + actionNameTransmit := "custom_transmit" + customActionGather := "gather" - // No other action is permitted by all three attributes - for _, action := range []string{actions.ActionNameCreate, actions.ActionNameUpdate, actions.ActionNameDelete} { - d, err := pdp.GetDecision(s.T().Context(), entity, &policy.Action{Name: action}, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(d) - s.False(d.Access, "Action %s should not be allowed", action) - } - }) + hierarchyTestAttrName := createAttrFQN(testAdvancedHierarchyNs, hierarchyAttrName) - s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ANY_OF FAILURE", func() { - // Entity with only partial entitlements - entity := s.createEntityWithProps("partial-entitlement-user", map[string]interface{}{ - "clearance": "secret", - "department": "finance", // not matching engineering - "country": []any{"us"}, - }) + topValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "top") + upperMiddleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "upper-middle") + middleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "middle") + lowerMiddleValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "lower-middle") + bottomValueFQN := createAttrValueFQN(testAdvancedHierarchyNs, hierarchyAttrName, "bottom") - // Resource with all three attribute types - combinedResource := &authz.Resource{ - EphemeralId: "three-attr-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN}, - }, + hierarchyAttribute := &policy.Attribute{ + Fqn: hierarchyTestAttrName, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: []*policy.Value{ + { + Fqn: topValueFQN, + Value: "top", }, - } - - // Test read access - should fail because department doesn't match - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) - - // Examine which attribute rule failed - s.Len(decision.Results, 1) - s.Equal("three-attr-resource", decision.Results[0].ResourceID) - - // Count passes and failures among data rules - passCount := 0 - failCount := 0 - for _, dataRule := range decision.Results[0].DataRuleResults { - if dataRule.Passed { - passCount++ - } else { - failCount++ - // Check that failure is for country attribute - s.Contains(dataRule.RuleDefinition.GetFqn(), "department") - } - } - s.Equal(2, passCount, "Two attributes should pass") - s.Equal(1, failCount, "One attribute should fail") - }) - - s.Run("Multiple attributes from different namespaces with different rules", func() { - // Entity with cross-namespace entitlements - entity := s.createEntityWithProps("cross-ns-rules-user", map[string]interface{}{ - "clearance": "secret", // HIERARCHY rule - "project": "alpha", // ANY_OF rule from secondary namespace - "platform": "cloud", // ANY_OF rule from secondary namespace - "country": []any{"us"}, // ALL_OF rule - }) - - // Resource with attributes from different namespaces and with different rules - complexResource := &authz.Resource{ - EphemeralId: "complex-multi-ns-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{ - testClassSecretFQN, // HIERARCHY rule, primary namespace - testCountryUSAFQN, // ALL_OF rule, primary namespace - testProjectAlphaFQN, // ANY_OF rule, secondary namespace - testPlatformCloudFQN, // ANY_OF rule, secondary namespace - }, - }, + { + Fqn: upperMiddleValueFQN, + Value: "upper-middle", }, - } - - // Test read access (all four allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{complexResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access) - - // Test delete access (only platform:cloud allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{complexResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access) // Overall fails because other attributes don't allow delete - - // Count how many attributes passed/failed for delete action - s.Len(decision.Results, 1) - passCount := 0 - failCount := 0 - for _, dataRule := range decision.Results[0].DataRuleResults { - if dataRule.Passed { - passCount++ - // Only the platform attribute should pass for delete - s.Contains(dataRule.RuleDefinition.GetFqn(), "platform") - } else { - failCount++ - } - } - s.Equal(1, passCount, "One attribute should pass (platform:cloud)") - s.Equal(3, failCount, "Three attributes should fail") - }) - - s.Run("Multiple HIERARCHY of duplicate same attribute value", func() { - // Create a resource with multiple classifications (hierarchy rule) - cascadingResource := &authz.Resource{ - EphemeralId: "classification-cascade-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{ - testClassSecretFQN, // Secret classification - testClassSecretFQN, // duplicate - testClassConfidentialFQN, // Confidential classification (lower than Secret) - testClassConfidentialFQN, // second duplicate - }, - }, + { + Fqn: middleValueFQN, + Value: "middle", }, - } - - // Entity with secret clearance (which should also give access to confidential) - entity := s.createEntityWithProps("secret-entity", map[string]interface{}{ - "clearance": "secret", - }) - - // Test read access - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") + { + Fqn: lowerMiddleValueFQN, + Value: "lower-middle", + }, + { + Fqn: bottomValueFQN, + Value: "bottom", + }, + }, + } - // Entity with confidential clearance (which should NOT give access to secret) - entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ - "clearance": "confidential", - }) + topMapping := createSimpleSubjectMapping( + topValueFQN, + "top", + []*policy.Action{testActionRead}, + ".properties.levels[]", + []string{"top"}, + ) + upperMiddleMapping := createSimpleSubjectMapping( + upperMiddleValueFQN, + "upper-middle", + []*policy.Action{testActionCreate}, + ".properties.levels[]", + []string{"upper-middle"}, + ) + middleMapping := createSimpleSubjectMapping( + middleValueFQN, + "middle", + []*policy.Action{testActionUpdate, {Name: actionNameTransmit}}, + ".properties.levels[]", + []string{"middle"}, + ) + lowerMiddleMapping := createSimpleSubjectMapping( + lowerMiddleValueFQN, + "lower-middle", + []*policy.Action{testActionDelete}, + ".properties.levels[]", + []string{"lower-middle"}, + ) + bottomMapping := createSimpleSubjectMapping( + bottomValueFQN, + "bottom", + []*policy.Action{{Name: customActionGather}}, + ".properties.levels[]", + []string{"bottom"}, + ) - // Test read access - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") + // Create a PDP with the hierarchy attribute and mappings + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{hierarchyAttribute}, + []*policy.SubjectMapping{ + topMapping, + upperMiddleMapping, + middleMapping, + lowerMiddleMapping, + bottomMapping, + }, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) - // Verify which rule failed - s.Len(decision.Results, 1) - s.Len(decision.Results[0].DataRuleResults, 1) - ruleResult := decision.Results[0].DataRuleResults[0] - s.NotEmpty(ruleResult.EntitlementFailures) - s.Equal(ruleResult.EntitlementFailures[0].AttributeValueFQN, testClassSecretFQN) + // Create an entity with every level in the hierarchy + entity := s.createEntityWithProps("hierarchy-test-entity", map[string]interface{}{ + "levels": []any{"top", "upper-middle", "middle", "lower-middle", "bottom"}, }) - s.Run("Multiple HIERARCHY of different levels", func() { - // Create a resource with multiple classifications (hierarchy rule) - cascadingResource := &authz.Resource{ - EphemeralId: "classification-cascade-resource", - Resource: &authz.Resource_AttributeValues_{ - AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{ - testClassSecretFQN, // Secret classification - testClassConfidentialFQN, // Confidential classification (lower than Secret) - }, - }, - }, - } + // Get entitlements for this entity + withComprehensiveHierarchy := true + entitlements, err := pdp.GetEntitlements(s.T().Context(), []*entityresolutionV2.EntityRepresentation{entity}, nil, withComprehensiveHierarchy) + s.Require().NoError(err) + s.Require().NotNil(entitlements) - // Entity with secret clearance (which should also give access to confidential) - entity := s.createEntityWithProps("secret-entity", map[string]interface{}{ - "clearance": "secret", - }) + // Find the entity's entitlements + entityEntitlement := findEntityEntitlements(entitlements, "hierarchy-test-entity") + s.Require().NotNil(entityEntitlement, "Entity entitlements should be found") + s.Require().Len(entityEntitlement.GetActionsPerAttributeValueFqn(), 5, "Number of entitlements should match expected") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), topValueFQN, "Top level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), upperMiddleValueFQN, "Upper-middle level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), middleValueFQN, "Middle level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), lowerMiddleValueFQN, "Lower-middle level should be present") + s.Contains(entityEntitlement.GetActionsPerAttributeValueFqn(), bottomValueFQN, "Bottom level should be present") - // Test read access - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") + // Verify actions for each level + topActions := entityEntitlement.GetActionsPerAttributeValueFqn()[topValueFQN] + s.Require().NotNil(topActions, "Top level actions should exist") + s.Len(topActions.GetActions(), 1) + s.Contains(actionNames(topActions.GetActions()), actions.ActionNameRead, "Top level should have read action") - // Entity with confidential clearance (which should NOT give access to secret) - entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ - "clearance": "confidential", - }) + upperMiddleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[upperMiddleValueFQN] + s.Require().NotNil(upperMiddleActions, "Upper-middle level actions should exist") + s.Len(upperMiddleActions.GetActions(), 2) + upperMiddleActionNames := actionNames(upperMiddleActions.GetActions()) + s.Contains(upperMiddleActionNames, actions.ActionNameCreate, "Upper-middle level should have create action") + s.Contains(upperMiddleActionNames, actions.ActionNameRead, "Upper-middle level should have read action") - // Test read access - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") + middleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[middleValueFQN] + s.Require().NotNil(middleActions, "Middle level actions should exist") + s.Len(middleActions.GetActions(), 4) + middleActionNames := actionNames(middleActions.GetActions()) + s.Contains(middleActionNames, actions.ActionNameUpdate, "Middle level should have update action") + s.Contains(middleActionNames, actionNameTransmit, "Middle level should have transmit action") + s.Contains(middleActionNames, actions.ActionNameCreate, "Middle level should have create action") + s.Contains(middleActionNames, actions.ActionNameRead, "Middle level should have read action") - // Verify which rule failed - s.Len(decision.Results, 1) - s.Len(decision.Results[0].DataRuleResults, 1) - ruleResult := decision.Results[0].DataRuleResults[0] - s.Len(ruleResult.EntitlementFailures, 1) - s.Equal(ruleResult.EntitlementFailures[0].AttributeValueFQN, testClassSecretFQN) - }) + lowerMiddleActions := entityEntitlement.GetActionsPerAttributeValueFqn()[lowerMiddleValueFQN] + s.Require().NotNil(lowerMiddleActions, "Lower-middle level actions should exist") + s.Len(lowerMiddleActions.GetActions(), 5) + lowerMiddleActionNames := actionNames(lowerMiddleActions.GetActions()) + s.Contains(lowerMiddleActionNames, actions.ActionNameDelete, "Lower-middle level should have delete action") + s.Contains(lowerMiddleActionNames, actions.ActionNameUpdate, "Lower-middle level should have update action") + s.Contains(lowerMiddleActionNames, actions.ActionNameCreate, "Lower-middle level should have create action") + s.Contains(lowerMiddleActionNames, actionNameTransmit, "Lower-middle level should have read action") + s.Contains(lowerMiddleActionNames, actions.ActionNameRead, "Lower-middle level should have read action") + + bottomActions := entityEntitlement.GetActionsPerAttributeValueFqn()[bottomValueFQN] + s.Require().NotNil(bottomActions, "Bottom level actions should exist") + s.Len(bottomActions.GetActions(), 6) + bottomActionNames := actionNames(bottomActions.GetActions()) + s.Contains(bottomActionNames, actions.ActionNameRead, "Bottom level should have read action") + s.Contains(bottomActionNames, actions.ActionNameUpdate, "Bottom level should have update action") + s.Contains(bottomActionNames, actions.ActionNameCreate, "Bottom level should have create action") + s.Contains(bottomActionNames, actions.ActionNameDelete, "Bottom level should have delete action") + s.Contains(bottomActionNames, actionNameTransmit, "Bottom level should have transmit action") + s.Contains(bottomActionNames, customActionGather, "Bottom level should have gather action") } // Helper functions for all tests @@ -1877,8 +1793,8 @@ func createResource(ephemeralID string, attributeValueFQNs ...string) *authz.Res } } -// createResources creates multiple resources, one for each attribute value FQN -func createResources(attributeValueFQNs ...string) []*authz.Resource { +// createResourcePerFqn creates multiple resources, one for each attribute value FQN +func createResourcePerFqn(attributeValueFQNs ...string) []*authz.Resource { resources := make([]*authz.Resource, len(attributeValueFQNs)) for i, fqn := range attributeValueFQNs { // Use the FQN itself as the resource ID instead of a generic "ephemeral-id-X" From 2ac9eb1566e62a4531a5a0393a5ff8b1284f1fff Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 20 May 2025 15:06:52 -0700 Subject: [PATCH 05/18] cleanup --- service/internal/access/v2/evaluate_test.go | 186 ++++++++++---------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 6609657300..b106ab07c8 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -15,30 +15,30 @@ import ( ) // Constants for namespaces and attribute FQNs -const ( +var ( // Base namespaces - baseNamespace = "https://namespace.com" - classificationFQN = baseNamespace + "/attr/classification" - departmentFQN = baseNamespace + "/attr/department" - projectFQN = baseNamespace + "/attr/project" - - // Classification values - classTopSecretFQN = classificationFQN + "/value/topsecret" - classSecretFQN = classificationFQN + "/value/secret" - classConfidentialFQN = classificationFQN + "/value/confidential" - classRestrictedFQN = classificationFQN + "/value/restricted" - classPublicFQN = classificationFQN + "/value/public" + baseNamespace = "https://namespace.com" + levelFQN = createAttrFQN(baseNamespace, "level") + departmentFQN = createAttrFQN(baseNamespace, "department") + projectFQN = createAttrFQN(baseNamespace, "project") + + // Leveled values + levelHighestFQN = createAttrValueFQN(baseNamespace, "level", "highest") + levelUpperMidFQN = createAttrValueFQN(baseNamespace, "level", "upper_mid") + levelMidFQN = createAttrValueFQN(baseNamespace, "level", "mid") + levelLowerMidFQN = createAttrValueFQN(baseNamespace, "level", "lower_mid") + levelLowestFQN = createAttrValueFQN(baseNamespace, "level", "lowest") // Department values - deptFinanceFQN = departmentFQN + "/value/finance" - deptMarketingFQN = departmentFQN + "/value/marketing" - deptLegalFQN = departmentFQN + "/value/legal" + deptFinanceFQN = createAttrValueFQN(baseNamespace, "department", "finance") + deptMarketingFQN = createAttrValueFQN(baseNamespace, "department", "marketing") + deptLegalFQN = createAttrValueFQN(baseNamespace, "department", "legal") // Project values - projectJusticeLeagueFQN = projectFQN + "/value/justiceleague" - projectAvengersFQN = projectFQN + "/value/avengers" - projectXmenFQN = projectFQN + "/value/xmen" - projectFantasicFourFQN = projectFQN + "/value/fantasticfour" + projectJusticeLeagueFQN = createAttrValueFQN(baseNamespace, "project", "justiceleague") + projectAvengersFQN = createAttrValueFQN(baseNamespace, "project", "avengers") + projectXmenFQN = createAttrValueFQN(baseNamespace, "project", "xmen") + projectFantasicFourFQN = createAttrValueFQN(baseNamespace, "project", "fantasticfour") ) var ( @@ -66,15 +66,15 @@ func (s *EvaluateTestSuite) SetupTest() { // Setup classification attribute (HIERARCHY) s.hierarchicalClassAttr = &policy.Attribute{ - Fqn: classificationFQN, + Fqn: levelFQN, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, Values: []*policy.Value{ // highest in hierarchy - {Fqn: classTopSecretFQN, Value: "topsecret"}, - {Fqn: classSecretFQN, Value: "secret"}, - {Fqn: classConfidentialFQN, Value: "confidential"}, - {Fqn: classRestrictedFQN, Value: "restricted"}, - {Fqn: classPublicFQN, Value: "public"}, + {Fqn: levelHighestFQN, Value: "highest"}, + {Fqn: levelUpperMidFQN, Value: "upper_mid"}, + {Fqn: levelMidFQN, Value: "mid"}, + {Fqn: levelLowerMidFQN, Value: "lower_mid"}, + {Fqn: levelLowestFQN, Value: "lowest"}, // lowest in hierarchy }, } @@ -103,25 +103,25 @@ func (s *EvaluateTestSuite) SetupTest() { // Setup accessible attribute values map s.accessibleAttrValues = map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ - classConfidentialFQN: { + levelMidFQN: { Attribute: s.hierarchicalClassAttr, - Value: &policy.Value{Fqn: classConfidentialFQN}, + Value: &policy.Value{Fqn: levelMidFQN}, }, - classSecretFQN: { + levelUpperMidFQN: { Attribute: s.hierarchicalClassAttr, - Value: &policy.Value{Fqn: classSecretFQN}, + Value: &policy.Value{Fqn: levelUpperMidFQN}, }, - classRestrictedFQN: { + levelLowerMidFQN: { Attribute: s.hierarchicalClassAttr, - Value: &policy.Value{Fqn: classRestrictedFQN}, + Value: &policy.Value{Fqn: levelLowerMidFQN}, }, - classTopSecretFQN: { + levelHighestFQN: { Attribute: s.hierarchicalClassAttr, - Value: &policy.Value{Fqn: classTopSecretFQN}, + Value: &policy.Value{Fqn: levelHighestFQN}, }, - classPublicFQN: { + levelLowestFQN: { Attribute: s.hierarchicalClassAttr, - Value: &policy.Value{Fqn: classPublicFQN}, + Value: &policy.Value{Fqn: levelLowestFQN}, }, deptFinanceFQN: { Attribute: s.anyOfDepartmentAttr, @@ -210,7 +210,7 @@ func (s *EvaluateTestSuite) TestAllOfRule() { }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ projectAvengersFQN: []*policy.Action{actionRead}, - // Missing classRestrictedFQN entirely + // Missing levelLowerMidFQN entirely }, expectedFailures: 1, }, @@ -432,122 +432,122 @@ func (s *EvaluateTestSuite) TestHierarchyRule() { { name: "entitled to highest value", resourceValueFQNs: []string{ - classSecretFQN, - classConfidentialFQN, + levelUpperMidFQN, + levelMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionRead}, // Entitled to highest value + levelUpperMidFQN: []*policy.Action{actionRead}, // Entitled to highest value }, expectedFailures: false, }, { name: "entitled to higher value", resourceValueFQNs: []string{ - classRestrictedFQN, + levelLowerMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classTopSecretFQN: []*policy.Action{actionRead}, // Entitled to highest value + levelHighestFQN: []*policy.Action{actionRead}, // Entitled to highest value }, expectedFailures: false, }, { name: "entitled to higher value 2", resourceValueFQNs: []string{ - classRestrictedFQN, + levelLowerMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionRead}, // Entitled to higher value + levelUpperMidFQN: []*policy.Action{actionRead}, // Entitled to higher value }, expectedFailures: false, }, { name: "multi higher entitlements", resourceValueFQNs: []string{ - classPublicFQN, + levelLowestFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionRead}, // higher - classConfidentialFQN: []*policy.Action{actionRead}, // higher + levelUpperMidFQN: []*policy.Action{actionRead}, // higher + levelMidFQN: []*policy.Action{actionRead}, // higher }, expectedFailures: false, }, { name: "higher and lower entitlements", resourceValueFQNs: []string{ - classRestrictedFQN, + levelLowerMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classPublicFQN: []*policy.Action{actionRead}, // lower - classSecretFQN: []*policy.Action{actionRead}, // higher + levelLowestFQN: []*policy.Action{actionRead}, // lower + levelUpperMidFQN: []*policy.Action{actionRead}, // higher }, expectedFailures: false, }, { name: "entitled to lower value but not highest", resourceValueFQNs: []string{ - classSecretFQN, - classConfidentialFQN, + levelUpperMidFQN, + levelMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, // Only entitled to lower value + levelMidFQN: []*policy.Action{actionRead}, // Only entitled to lower value }, expectedFailures: true, }, { name: "entitled to wrong action on highest value", resourceValueFQNs: []string{ - classSecretFQN, - classConfidentialFQN, + levelUpperMidFQN, + levelMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionCreate}, // Wrong action + levelUpperMidFQN: []*policy.Action{actionCreate}, // Wrong action }, expectedFailures: true, }, { name: "highest value from multiple resources", resourceValueFQNs: []string{ - classConfidentialFQN, - classTopSecretFQN, // This is highest - classRestrictedFQN, + levelMidFQN, + levelHighestFQN, // This is highest + levelLowerMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classTopSecretFQN: []*policy.Action{actionRead}, + levelHighestFQN: []*policy.Action{actionRead}, }, expectedFailures: false, }, { name: "entitled to much higher value in hierarchy than requested", resourceValueFQNs: []string{ - classPublicFQN, // Lowest in hierarchy (index 4) + levelLowestFQN, // Lowest in hierarchy (index 4) }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classTopSecretFQN: []*policy.Action{actionRead}, // Highest in hierarchy (index 0) + levelHighestFQN: []*policy.Action{actionRead}, // Highest in hierarchy (index 0) }, expectedFailures: false, // Should pass with the fix }, { name: "entitled to multiple values higher in hierarchy than requested", resourceValueFQNs: []string{ - classRestrictedFQN, // Lower in hierarchy (index 3) - classPublicFQN, // Lowest in hierarchy (index 4) + levelLowerMidFQN, // Lower in hierarchy (index 3) + levelLowestFQN, // Lowest in hierarchy (index 4) }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ // No entitlement for exact matches - classTopSecretFQN: []*policy.Action{actionRead}, // Much higher in hierarchy (index 0) - classSecretFQN: []*policy.Action{actionRead}, // Higher in hierarchy (index 1) + levelHighestFQN: []*policy.Action{actionRead}, // Much higher in hierarchy (index 0) + levelUpperMidFQN: []*policy.Action{actionRead}, // Higher in hierarchy (index 1) }, expectedFailures: false, // Should pass with the fix }, { name: "entitled to value higher than highest requested but wrong action", resourceValueFQNs: []string{ - classConfidentialFQN, // Middle in hierarchy (index 2) - classRestrictedFQN, // Lower in hierarchy (index 3) + levelMidFQN, // Middle in hierarchy (index 2) + levelLowerMidFQN, // Lower in hierarchy (index 3) }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionCreate}, // Higher but wrong action - classTopSecretFQN: []*policy.Action{actionCreate}, // Highest but wrong action + levelUpperMidFQN: []*policy.Action{actionCreate}, // Higher but wrong action + levelHighestFQN: []*policy.Action{actionCreate}, // Highest but wrong action }, expectedFailures: true, // Should fail due to wrong action }, @@ -555,7 +555,7 @@ func (s *EvaluateTestSuite) TestHierarchyRule() { name: "empty resource list", resourceValueFQNs: []string{}, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionRead}, + levelUpperMidFQN: []*policy.Action{actionRead}, }, expectedFailures: false, // No resources to check, should pass }, @@ -590,12 +590,12 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() { name: "all-of rule passing", definition: s.allOfProjectAttr, resourceValues: []string{ - classConfidentialFQN, - classRestrictedFQN, + levelMidFQN, + levelLowerMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, - classRestrictedFQN: []*policy.Action{actionRead}, + levelMidFQN: []*policy.Action{actionRead}, + levelLowerMidFQN: []*policy.Action{actionRead}, }, expectPass: true, expectError: false, @@ -617,11 +617,11 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() { name: "hierarchy rule passing", definition: s.hierarchicalClassAttr, resourceValues: []string{ - classSecretFQN, - classConfidentialFQN, + levelUpperMidFQN, + levelMidFQN, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classSecretFQN: []*policy.Action{actionRead}, + levelUpperMidFQN: []*policy.Action{actionRead}, }, expectPass: true, expectError: false, @@ -629,13 +629,13 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() { { name: "unspecified rule type", definition: &policy.Attribute{ - Fqn: classificationFQN, + Fqn: levelFQN, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED, Values: []*policy.Value{ - {Fqn: classConfidentialFQN}, + {Fqn: levelMidFQN}, }, }, - resourceValues: []string{classConfidentialFQN}, + resourceValues: []string{levelMidFQN}, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, expectPass: false, expectError: true, @@ -670,13 +670,13 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { name: "all rules passing", resourceAttrs: &authz.Resource_AttributeValues{ Fqns: []string{ - classConfidentialFQN, + levelMidFQN, deptFinanceFQN, }, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, - deptFinanceFQN: []*policy.Action{actionRead}, + levelMidFQN: []*policy.Action{actionRead}, + deptFinanceFQN: []*policy.Action{actionRead}, }, expectAccessible: true, expectError: false, @@ -685,13 +685,13 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { name: "all rules passing - non lower-cased FQNs", resourceAttrs: &authz.Resource_AttributeValues{ Fqns: []string{ - strings.ToUpper(classConfidentialFQN), + strings.ToUpper(levelMidFQN), strings.ToUpper(deptFinanceFQN), }, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, - deptFinanceFQN: []*policy.Action{actionRead}, + levelMidFQN: []*policy.Action{actionRead}, + deptFinanceFQN: []*policy.Action{actionRead}, }, expectAccessible: true, expectError: false, @@ -700,13 +700,13 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { name: "one rule failing", resourceAttrs: &authz.Resource_AttributeValues{ Fqns: []string{ - classConfidentialFQN, + levelMidFQN, deptFinanceFQN, }, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, - deptFinanceFQN: []*policy.Action{actionCreate}, // Wrong action + levelMidFQN: []*policy.Action{actionRead}, + deptFinanceFQN: []*policy.Action{actionCreate}, // Wrong action }, expectAccessible: false, expectError: false, @@ -715,12 +715,12 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { name: "unknown attribute value FQN", resourceAttrs: &authz.Resource_AttributeValues{ Fqns: []string{ - classConfidentialFQN, + levelMidFQN, "https://namespace.com/attr/department/value/unknown", // This FQN doesn't exist in accessibleAttributeValues }, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, + levelMidFQN: []*policy.Action{actionRead}, }, expectAccessible: false, expectError: true, @@ -772,12 +772,12 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { resource: &authz.Resource{ Resource: &authz.Resource_AttributeValues_{ AttributeValues: &authz.Resource_AttributeValues{ - Fqns: []string{classConfidentialFQN}, + Fqns: []string{levelMidFQN}, }, }, }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - classConfidentialFQN: []*policy.Action{actionRead}, + levelMidFQN: []*policy.Action{actionRead}, }, expectError: false, }, From fc74ba2d61060375038a788bb5c5a82295483bef Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 07:36:17 -0700 Subject: [PATCH 06/18] copilot fixes --- service/internal/access/v2/helpers.go | 2 +- service/internal/access/v2/just_in_time_pdp.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 16e82087b1..6737fdc62f 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -25,7 +25,7 @@ func getDefinition(valueFQN string, allDefinitionsByDefFQN map[string]*policy.At definition, ok := allDefinitionsByDefFQN[def.FQN()] if !ok { - return nil, fmt.Errorf("definition not found: %w", err) + return nil, fmt.Errorf("definition not found for FQN: %s", def.FQN()) } return definition, nil } diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 3b35af0a21..d0549b5afc 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -94,9 +94,10 @@ func (p *JustInTimePDP) GetDecision( p.logger.DebugContext(ctx, "getting decision - resolving token") entityRepresentations, err = p.resolveEntitiesFromToken(ctx, entityIdentifier.GetToken(), skipEnvironmentEntities) + // TODO: implement this case case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: p.logger.DebugContext(ctx, "getting decision - resolving registered resource value FQN") - // TODO: implement this case + return nil, false, errors.New("registered resources not yet implemented") default: p.logger.ErrorContext(ctx, "invalid entity identifier type", slog.String("error", ErrInvalidEntityType.Error()), slog.String("type", fmt.Sprintf("%T", entityIdentifier.GetIdentifier()))) From 62ec1515c3b9b5aa54ecf3a84688ce7c220ce1b8 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 07:54:02 -0700 Subject: [PATCH 07/18] error logging suggestions --- service/internal/access/v2/evaluate.go | 3 +- .../internal/access/v2/just_in_time_pdp.go | 27 ++++-------- service/internal/access/v2/pdp.go | 41 +++++-------------- 3 files changed, 20 insertions(+), 51 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 73a9ee65c2..83b9c51ece 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -2,6 +2,7 @@ package access import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -88,7 +89,7 @@ func evaluateResourceAttributeValues( dataRuleResult, err := evaluateDefinition(ctx, logger, entitlements, action, resourceValueFQNs, definition) if err != nil { - return nil, fmt.Errorf("%w: %s", ErrFailedEvaluation, err.Error()) + return nil, errors.Join(ErrFailedEvaluation, err) } if !dataRuleResult.Passed { passed = false diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index d0549b5afc..06c58f7a1f 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -36,7 +36,6 @@ func NewJustInTimePDP( var err error if sdk == nil { - l.ErrorContext(ctx, "invalid arguments", slog.String("error", ErrMissingRequiredSDK.Error())) return nil, ErrMissingRequiredSDK } if l == nil { @@ -53,17 +52,14 @@ func NewJustInTimePDP( allAttributes, err := p.fetchAllDefinitions(ctx) if err != nil { - l.ErrorContext(ctx, "failed to fetch all attribute definitions", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to fetch all attribute definitions: %w", err) } allSubjectMappings, err := p.fetchAllSubjectMappings(ctx) if err != nil { - l.ErrorContext(ctx, "failed to fetch all subject mappings", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to fetch all subject mappings: %w", err) } pdp, err := NewPolicyDecisionPoint(ctx, l, allAttributes, allSubjectMappings) if err != nil { - l.ErrorContext(ctx, "failed to create new policy decision point", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to create new policy decision point: %w", err) } p.pdp = pdp @@ -100,11 +96,9 @@ func (p *JustInTimePDP) GetDecision( return nil, false, errors.New("registered resources not yet implemented") default: - p.logger.ErrorContext(ctx, "invalid entity identifier type", slog.String("error", ErrInvalidEntityType.Error()), slog.String("type", fmt.Sprintf("%T", entityIdentifier.GetIdentifier()))) return nil, false, ErrInvalidEntityType } if err != nil { - p.logger.ErrorContext(ctx, "failed to resolve entity identifier", slog.String("error", err.Error())) return nil, false, fmt.Errorf("failed to resolve entity identifier: %w", err) } @@ -113,11 +107,10 @@ func (p *JustInTimePDP) GetDecision( for _, entityRep := range entityRepresentations { d, err := p.pdp.GetDecision(ctx, entityRep, action, resources) if err != nil { - p.logger.ErrorContext(ctx, "failed to get decision", slog.String("error", err.Error())) - return nil, false, fmt.Errorf("failed to get decision: %w", err) + // TODO: is it safe to log the entity representation? + return nil, false, fmt.Errorf("failed to get decision on entityRepresentation %+v: %w", entityRep, err) } if d == nil { - p.logger.ErrorContext(ctx, "decision is nil") return nil, false, fmt.Errorf("decision is nil: %w", err) } if !d.Access { @@ -152,30 +145,28 @@ func (p *JustInTimePDP) GetEntitlements( entityRepresentations, err = p.resolveEntitiesFromToken(ctx, entityIdentifier.GetToken(), skipEnvironmentEntities) case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: p.logger.DebugContext(ctx, "getting decision - resolving registered resource value FQN") + return nil, errors.New("registered resources not yet implemented") // TODO: implement this case default: - p.logger.ErrorContext(ctx, "invalid entity identifier type", slog.String("error", ErrInvalidEntityType.Error()), slog.String("type", fmt.Sprintf("%T", entityIdentifier.GetIdentifier()))) - return nil, ErrInvalidEntityType + return nil, fmt.Errorf("entity type %T: %w", entityIdentifier.GetIdentifier(), ErrInvalidEntityType) } if err != nil { - p.logger.ErrorContext(ctx, "failed to resolve entity identifier", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to resolve entity identifier: %w", err) + return nil, fmt.Errorf("failed to resolve entities from entity identifier: %w", err) } matchedSubjectMappings, err := p.getMatchedSubjectMappings(ctx, entityRepresentations) if err != nil { - p.logger.ErrorContext(ctx, "failed to get matched subject mappings", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to get matched subject mappings: %w", err) } // If no subject mappings are found, return empty entitlements if matchedSubjectMappings == nil { - p.logger.ErrorContext(ctx, "matched subject mappings is empty") + // TODO: is this an error case? + p.logger.DebugContext(ctx, "matched subject mappings is empty") return nil, nil } entitlements, err := p.pdp.GetEntitlements(ctx, entityRepresentations, matchedSubjectMappings, withComprehensiveHierarchy) if err != nil { - p.logger.ErrorContext(ctx, "failed to get entitlements", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to get entitlements: %w", err) } return entitlements, nil @@ -195,7 +186,6 @@ func (p *JustInTimePDP) getMatchedSubjectMappings( for _, entity := range entityRep.GetAdditionalProps() { flattened, err := flattening.Flatten(entity.AsMap()) if err != nil { - p.logger.ErrorContext(ctx, "failed to flatten entity representation", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to flatten entity representation: %w", err) } for _, item := range flattened.Items { @@ -214,7 +204,6 @@ func (p *JustInTimePDP) getMatchedSubjectMappings( } rsp, err := p.sdk.SubjectMapping.MatchSubjectMappings(ctx, req) if err != nil { - p.logger.ErrorContext(ctx, "failed to match subject mappings", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to match subject mappings: %w", err) } return rsp.GetSubjectMappings(), nil @@ -235,7 +224,6 @@ func (p *JustInTimePDP) fetchAllDefinitions(ctx context.Context) ([]*policy.Attr }, }) if err != nil { - p.logger.ErrorContext(ctx, "failed to list attributes", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to list attributes: %w", err) } @@ -263,7 +251,6 @@ func (p *JustInTimePDP) fetchAllSubjectMappings(ctx context.Context) ([]*policy. }, }) if err != nil { - p.logger.ErrorContext(ctx, "failed to list subject mappings", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to list subject mappings: %w", err) } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 8d48185dd1..90ab4af737 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -76,8 +76,7 @@ func NewPolicyDecisionPoint( } if allAttributeDefinitions == nil || allSubjectMappings == nil { - l.ErrorContext(ctx, "invalid arguments", slog.String("error", ErrMissingRequiredPolicy.Error())) - return nil, ErrMissingRequiredPolicy + return nil, fmt.Errorf("invalid arguments: %w", ErrMissingRequiredPolicy) } // Build lookup maps to in-memory policy @@ -85,7 +84,6 @@ func NewPolicyDecisionPoint( allEntitleableAttributesByValueFQN := make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) for _, attr := range allAttributeDefinitions { if err := validateAttribute(attr); err != nil { - l.Error("invalid attribute definition", slog.String("error", err.Error())) return nil, fmt.Errorf("invalid attribute definition: %w", err) } allAttributesByDefinitionFQN[attr.GetFqn()] = attr @@ -102,7 +100,7 @@ func NewPolicyDecisionPoint( for _, sm := range allSubjectMappings { if err := validateSubjectMapping(sm); err != nil { - l.WarnContext(ctx, "invalid subject mapping - skipping", slog.String("error", err.Error()), slog.Any("subject mapping", sm)) + l.WarnContext(ctx, "invalid subject mapping - skipping", slog.Any("error", err), slog.Any("subject mapping", sm)) continue } mappedValue := sm.GetAttributeValue() @@ -114,7 +112,6 @@ func NewPolicyDecisionPoint( // Take subject mapping's attribute value and its definition from memory parentDefinition, err := getDefinition(mappedValueFQN, allAttributesByDefinitionFQN) if err != nil { - l.Error("failed to get attribute definition", slog.String("error", err.Error())) return nil, fmt.Errorf("failed to get attribute definition: %w", err) } mappedValue.SubjectMappings = []*policy.SubjectMapping{sm} @@ -147,7 +144,6 @@ func (p *PolicyDecisionPoint) GetDecision( p.logger.DebugContext(ctx, "getting decision", loggable...) if err := validateGetDecision(entityRepresentation, action, resources); err != nil { - p.logger.ErrorContext(ctx, "invalid input parameters", append(loggable, slog.String("error", err.Error()))...) return nil, err } @@ -174,33 +170,26 @@ func (p *PolicyDecisionPoint) GetDecision( attributeAndValue, ok := p.allEntitleableAttributesByValueFQN[valueFQN] if !ok { - loggable = append(loggable, slog.String("error", ErrInvalidResource.Error()), slog.String("value", valueFQN), slog.Any("resource", resource)) - p.logger.ErrorContext(ctx, "resource value FQN not found in memory", loggable...) - return nil, ErrInvalidResource + return nil, fmt.Errorf("resource value FQN not found in memory [%s]: %w", valueFQN, ErrInvalidResource) } decisionableAttributes[valueFQN] = attributeAndValue err := populateHigherValuesIfHierarchy(ctx, p.logger, valueFQN, attributeAndValue.GetAttribute(), p.allEntitleableAttributesByValueFQN, decisionableAttributes) if err != nil { - loggable = append(loggable, slog.String("error", err.Error()), slog.String("value", valueFQN), slog.Any("resource", resource)) - p.logger.ErrorContext(ctx, "error populating higher hierarchy attribute values", loggable...) - return nil, err + return nil, fmt.Errorf("error populating higher hierarchy attribute values: %w", err) } } default: // default should never happen as we validate above - p.logger.ErrorContext(ctx, "invalid resource type", append(loggable, slog.String("error", ErrInvalidResource.Error()), slog.Any("resource", resource))...) - return nil, ErrInvalidResource + return nil, fmt.Errorf("invalid resource type [%T]: %w", resource.GetResource(), ErrInvalidResource) } } p.logger.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable attribute values count", len(decisionableAttributes))) // Resolve them to their entitled FQNs and the actions available on each entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation) if err != nil { - // TODO: is it safe to log entities/entity representations? - p.logger.ErrorContext(ctx, "error evaluating subject mappings for entitlement", append(loggable, slog.String("error", err.Error()), slog.Any("entity", entityRepresentation))...) - return nil, err + return nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } p.logger.DebugContext(ctx, "evaluated subject mappings", slog.String("entity originalId", entityRepresentation.GetOriginalId()), slog.Any("entitled FQNs to actions", entitledFQNsToActions)) @@ -212,8 +201,7 @@ func (p *PolicyDecisionPoint) GetDecision( for idx, resource := range resources { resourceDecision, err := getResourceDecision(ctx, p.logger, decisionableAttributes, entitledFQNsToActions, action, resource) if err != nil || resourceDecision == nil { - p.logger.ErrorContext(ctx, "error evaluating decision", append(loggable, slog.String("error", err.Error()), slog.Any("resource", resource))...) - return nil, err + return nil, fmt.Errorf("error evaluating a discision on resource [%v]: %w", resource, err) } if !resourceDecision.Passed { @@ -245,8 +233,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( err := validateEntityRepresentations(entityRepresentations) if err != nil { - p.logger.Error("invalid input parameters", append(loggable, slog.String("error", err.Error()))...) - return nil, err + return nil, fmt.Errorf("invalid input parameters: %w", err) } var entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue @@ -256,8 +243,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( p.logger.DebugContext(ctx, "getting entitlements with matched subject mappings", loggable...) entitleableAttributes, err = getFilteredEntitleableAttributes(optionalMatchedSubjectMappings, p.allEntitleableAttributesByValueFQN) if err != nil { - p.logger.ErrorContext(ctx, "error filtering entitleable attributes from matched subject mappings", append(loggable, slog.String("error", err.Error()))...) - return nil, err + return nil, fmt.Errorf("error filtering entitleable attributes from matched subject mappings: %w", err) } } else { // Otherwise, use all entitleable attributes @@ -268,9 +254,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( // Resolve them to their entitled FQNs and the actions available on each entityIDsToFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(entitleableAttributes, entityRepresentations) if err != nil { - // TODO: is it safe to log entities/entity representations? - p.logger.ErrorContext(ctx, "error evaluating subject mappings for entitlement", append(loggable, slog.String("error", err.Error()), slog.Any("entities", entityRepresentations))...) - return nil, err + return nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } var result []*authz.EntityEntitlements @@ -292,10 +276,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( if withComprehensiveHierarchy { err = populateLowerValuesIfHierarchy(valueFQN, entitleableAttributes, entitledActions, actionsPerAttributeValueFqn) if err != nil { - p.logger.ErrorContext(ctx, "error populating comprehensive lower hierarchy values", - append(loggable, slog.String("error", err.Error()), slog.String("value", valueFQN), slog.String("entityID", entityID))..., - ) - return nil, err + return nil, fmt.Errorf("error populating comprehensive lower hierarchy values of valueFQN [%s] for entityID [%s]: %w", valueFQN, entityID, err) } } } From 31a4f3a71378fd8c9a83279e74a6832de2d91562 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 09:36:40 -0700 Subject: [PATCH 08/18] lint fixes --- service/internal/access/v2/helpers_test.go | 4 ++-- service/internal/access/v2/pdp_test.go | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index 275cba1889..825aaa8246 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -77,7 +77,7 @@ func TestGetFilteredEntitleableAttributes(t *testing.T) { confidentialFQN := "https://example.org/attr/classification/value/confidential" secretFQN := "https://example.org/attr/classification/value/secret" - departmentFQN := "https://example.org/attr/department" + deptFQN := "https://example.org/attr/department" hrFQN := "https://example.org/attr/department/value/hr" financeFQN := "https://example.org/attr/department/value/finance" itFQN := "https://example.org/attr/department/value/it" @@ -91,7 +91,7 @@ func TestGetFilteredEntitleableAttributes(t *testing.T) { } departmentAttr := &policy.Attribute{ - Fqn: departmentFQN, + Fqn: deptFQN, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, } diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 648daaa437..0dfb806a30 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -703,7 +703,6 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { // Test_GetDecision_CombinedAttributeRules tests scenarios with combinations of different attribute rules on a single resource func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() { - f := s.fixtures // Create a PDP with all attribute types (HIERARCHY, ANY_OF, ALL_OF) @@ -1357,7 +1356,6 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { // TestGetEntitlements tests the functionality of retrieving entitlements for entities func (s *PDPTestSuite) Test_GetEntitlements() { - f := s.fixtures // Create a PDP with attributes and mappings From 0a94bddeca8010cc8e95ecfe56a2874c62fbd5b1 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 09:48:41 -0700 Subject: [PATCH 09/18] name errors where they are utilized --- service/internal/access/v2/errors.go | 22 ------------------- service/internal/access/v2/evaluate.go | 9 ++++++++ service/internal/access/v2/helpers.go | 6 +++++ .../internal/access/v2/just_in_time_pdp.go | 5 +++++ service/internal/access/v2/pdp.go | 14 +++++++----- service/internal/access/v2/validators.go | 11 ++++++++-- 6 files changed, 38 insertions(+), 29 deletions(-) delete mode 100644 service/internal/access/v2/errors.go diff --git a/service/internal/access/v2/errors.go b/service/internal/access/v2/errors.go deleted file mode 100644 index ba40217385..0000000000 --- a/service/internal/access/v2/errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package access - -import "errors" - -var ( - ErrMissingRequiredSDK = errors.New("access: missing required SDK") - ErrMissingRequiredLogger = errors.New("access: missing required logger") - ErrMissingEntityResolutionServiceSDKConnection = errors.New("access: missing required entity resolution SDK connection, cannot be nil") - ErrMissingRequiredPolicy = errors.New("access: both attribute definitions and subject mappings must be provided or neither") - ErrInvalidEntityType = errors.New("access: invalid entity type") - ErrInvalidAttributeDefinition = errors.New("access: invalid attribute definition") - ErrInvalidSubjectMapping = errors.New("access: invalid subject mapping") - ErrInvalidEntitledFQNsToActions = errors.New("access: invalid entitled FQNs to actions") - ErrInvalidResource = errors.New("access: invalid resource") - ErrInvalidEntityChain = errors.New("access: invalid entity chain") - ErrInvalidAction = errors.New("access: invalid action") - ErrFQNNotFound = errors.New("access: attribute value FQN not found in memory") - ErrDefinitionNotFound = errors.New("access: definition not found for FQN") - ErrFailedEvaluation = errors.New("access: failed to evaluate definition") - ErrMissingRequiredSpecifiedRule = errors.New("access: AttributeDefinition rule cannot be unspecified") - ErrUnrecognizedRule = errors.New("access: unrecognized AttributeDefinition rule") -) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 83b9c51ece..e030e4a6ea 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -14,6 +14,15 @@ import ( "github.com/opentdf/platform/service/logger" ) +var ( + ErrInvalidResource = errors.New("access: invalid resource") + ErrFQNNotFound = errors.New("access: attribute value FQN not found in memory") + ErrDefinitionNotFound = errors.New("access: definition not found for FQN") + ErrFailedEvaluation = errors.New("access: failed to evaluate definition") + ErrMissingRequiredSpecifiedRule = errors.New("access: AttributeDefinition rule cannot be unspecified") + ErrUnrecognizedRule = errors.New("access: unrecognized AttributeDefinition rule") +) + // getResourceDecision evaluates the access decision for a single resource, driving the flows // between entitlement checks for the different types of resources func getResourceDecision( diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 6737fdc62f..7ad423a4f5 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -2,6 +2,7 @@ package access import ( "context" + "errors" "fmt" "log/slog" @@ -12,6 +13,11 @@ import ( "github.com/opentdf/platform/service/logger" ) +var ( + ErrInvalidSubjectMapping = errors.New("access: invalid subject mapping") + ErrInvalidAttributeDefinition = errors.New("access: invalid attribute definition") +) + // getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions canmap func getDefinition(valueFQN string, allDefinitionsByDefFQN map[string]*policy.Attribute) (*policy.Attribute, error) { parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](valueFQN) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 06c58f7a1f..729d85b18d 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -19,6 +19,11 @@ import ( "github.com/opentdf/platform/service/logger" ) +var ( + ErrMissingRequiredSDK = errors.New("access: missing required SDK") + ErrInvalidEntityType = errors.New("access: invalid entity type") +) + type JustInTimePDP struct { logger *logger.Logger sdk *otdfSDK.SDK diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 90ab4af737..dd7e03299b 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -2,6 +2,7 @@ package access import ( "context" + "errors" "fmt" "log/slog" "strconv" @@ -49,11 +50,14 @@ type PolicyDecisionPoint struct { // allRegisteredResourcesByValueFQN map[string]*policy.RegisteredResourceValue } -var defaultFallbackLoggerConfig = logger.Config{ - Level: "info", - Type: "json", - Output: "stdout", -} +var ( + defaultFallbackLoggerConfig = logger.Config{ + Level: "info", + Type: "json", + Output: "stdout", + } + ErrMissingRequiredPolicy = errors.New("access: both attribute definitions and subject mappings must be provided or neither") +) // PolicyDecisionPoint 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. diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index 4cbd03d6d9..e1de639c3b 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -1,6 +1,7 @@ package access import ( + "errors" "fmt" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -10,6 +11,12 @@ import ( "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" ) +var ( + ErrInvalidAction = errors.New("access: invalid action") + ErrInvalidEntityChain = errors.New("access: invalid entity chain") + ErrInvalidEntitledFQNsToActions = errors.New("access: invalid entitled FQNs to actions") +) + // validateGetDecision validates the input parameters for GetDecision: // // - entityRepresentation: must not be nil @@ -19,8 +26,8 @@ func validateGetDecision(entityRepresentation *entityresolutionV2.EntityRepresen if err := validateEntityRepresentations([]*entityresolutionV2.EntityRepresentation{entityRepresentation}); err != nil { return fmt.Errorf("invalid entity representation: %w", err) } - if action == nil { - return fmt.Errorf("action is nil: %w", ErrInvalidAction) + if action.GetName() == "" { + return fmt.Errorf("action required with name: %w", ErrInvalidAction) } if len(resources) == 0 { return fmt.Errorf("resources are empty: %w", ErrInvalidResource) From 94dc733877966af8a395a699ad4a7ceb60a57559 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 09:56:59 -0700 Subject: [PATCH 10/18] copilot suggestions --- service/internal/access/v2/just_in_time_pdp.go | 2 +- service/internal/access/v2/pdp.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 729d85b18d..9157f60be4 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -320,7 +320,7 @@ func (p *JustInTimePDP) resolveEntitiesFromToken( } entityChains := ersResp.GetEntityChains() if len(entityChains) != 1 { - return nil, fmt.Errorf("received %d entity chains in ERS response and expected exactly 1: %w", len(entityChains), err) + return nil, fmt.Errorf("received %d entity chains in ERS response but expected exactly 1", len(entityChains)) } return p.resolveEntitiesFromEntityChain(ctx, entityChains[0], skipEnvironmentEntities) } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index dd7e03299b..c3f9b12396 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -205,7 +205,7 @@ func (p *PolicyDecisionPoint) GetDecision( for idx, resource := range resources { resourceDecision, err := getResourceDecision(ctx, p.logger, decisionableAttributes, entitledFQNsToActions, action, resource) if err != nil || resourceDecision == nil { - return nil, fmt.Errorf("error evaluating a discision on resource [%v]: %w", resource, err) + return nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } if !resourceDecision.Passed { From 1e8392e39b3411de5cb1948dd3c189afc954e25f Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 10:45:05 -0700 Subject: [PATCH 11/18] gemini comments --- service/internal/access/v2/evaluate.go | 2 ++ service/internal/access/v2/just_in_time_pdp.go | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index e030e4a6ea..fe5fbb7374 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -60,6 +60,7 @@ func getResourceDecision( } // evaluateResourceAttributeValues evaluates a list of attribute values against the action and entitlements +// and lowercases the FQNs to ensure case-insensitive matching func evaluateResourceAttributeValues( ctx context.Context, logger *logger.Logger, @@ -72,6 +73,7 @@ func evaluateResourceAttributeValues( // Group value FQNs by parent definition definitionFqnToValueFqns := make(map[string][]string) definitionsLookup := make(map[string]*policy.Attribute) + for idx, valueFQN := range resourceAttributeValues.GetFqns() { // lowercase the value FQN to ensure case-insensitive matching valueFQN = strings.ToLower(valueFQN) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 9157f60be4..ff59531910 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -112,8 +112,7 @@ func (p *JustInTimePDP) GetDecision( for _, entityRep := range entityRepresentations { d, err := p.pdp.GetDecision(ctx, entityRep, action, resources) if err != nil { - // TODO: is it safe to log the entity representation? - return nil, false, fmt.Errorf("failed to get decision on entityRepresentation %+v: %w", entityRep, err) + return nil, false, fmt.Errorf("failed to get decision for entityRepresentation with original id [%s]: %w", entityRep.GetOriginalId(), err) } if d == nil { return nil, false, fmt.Errorf("decision is nil: %w", err) @@ -276,8 +275,7 @@ func (p *JustInTimePDP) resolveEntitiesFromEntityChain( entityChain *entity.EntityChain, skipEnvironmentEntities bool, ) ([]*entityresolutionV2.EntityRepresentation, error) { - // TODO: is it safe to log the entity chain? - p.logger.DebugContext(ctx, "resolving entities from entity chain", slog.String("entityChain", entityChain.String()), slog.Bool("skipEnvironmentEntities", skipEnvironmentEntities)) + p.logger.DebugContext(ctx, "resolving entities from entity chain", slog.String("entityChain ID", entityChain.GetEphemeralId()), slog.Bool("skipEnvironmentEntities", skipEnvironmentEntities)) var filteredEntities []*entity.Entity if skipEnvironmentEntities { From dfae10132858349dbe025140439636441cfe1c1a Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 21 May 2025 14:45:47 -0700 Subject: [PATCH 12/18] return errors more effectively if encountering registered resources and they are not supported --- service/internal/access/v2/evaluate.go | 8 ++------ service/internal/access/v2/pdp.go | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index fe5fbb7374..b650ae32a5 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -44,19 +44,15 @@ func getResourceDecision( ) switch resource.GetResource().(type) { + // TODO: handle registered resources case *authz.Resource_RegisteredResourceValueFqn: - // TODO: handle registered resources - // return evaluateRegisteredResourceValue(ctx, resource.GetRegisteredResourceValueFqn(), action, entitlements, accessibleAttributeValues) - + return nil, fmt.Errorf("registered resources not supported yet: %w", ErrInvalidResource) case *authz.Resource_AttributeValues_: return evaluateResourceAttributeValues(ctx, logger, resource.GetAttributeValues(), resource.GetEphemeralId(), action, entitlements, accessibleAttributeValues) default: return nil, fmt.Errorf("unsupported resource type: %w", ErrInvalidResource) } - - // should never reach here - return nil, fmt.Errorf("unable to get resource resource decision: %w", ErrInvalidResource) } // evaluateResourceAttributeValues evaluates a list of attribute values against the action and entitlements diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index c3f9b12396..b98da41bc6 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -161,8 +161,9 @@ func (p *PolicyDecisionPoint) GetDecision( } switch resource.GetResource().(type) { + // TODO: handle gathering decisionable attributes of registered resources case *authz.Resource_RegisteredResourceValueFqn: - // TODO: handle gathering decisionable attributes of registered resources + return nil, fmt.Errorf("registered resource value FQN not supported: %w", ErrInvalidResource) case *authz.Resource_AttributeValues_: for _, valueFQN := range resource.GetAttributeValues().GetFqns() { @@ -190,6 +191,7 @@ func (p *PolicyDecisionPoint) GetDecision( } } p.logger.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable attribute values count", len(decisionableAttributes))) + // Resolve them to their entitled FQNs and the actions available on each entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation) if err != nil { From bcf49597e4aad73cf50943a64a5553c741f43260 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Thu, 22 May 2025 08:23:30 -0700 Subject: [PATCH 13/18] logging suggestions --- service/internal/access/v2/pdp.go | 34 ++++++++++++++----------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index b98da41bc6..55f534def4 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -140,12 +140,9 @@ func (p *PolicyDecisionPoint) GetDecision( action *policy.Action, resources []*authz.Resource, ) (*Decision, error) { - loggable := []any{ - slog.String("entity ID", entityRepresentation.GetOriginalId()), - slog.String("action", action.GetName()), - slog.Int("resources total", len(resources)), - } - p.logger.DebugContext(ctx, "getting decision", loggable...) + l := p.logger.With("entityID", entityRepresentation.GetOriginalId()) + l = l.With("action", action.GetName()) + l.DebugContext(ctx, "getting decision", slog.Int("resourcesCount", len(resources))) if err := validateGetDecision(entityRepresentation, action, resources); err != nil { return nil, err @@ -190,14 +187,14 @@ func (p *PolicyDecisionPoint) GetDecision( return nil, fmt.Errorf("invalid resource type [%T]: %w", resource.GetResource(), ErrInvalidResource) } } - p.logger.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable attribute values count", len(decisionableAttributes))) + l.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionableAttributeValuesCount", len(decisionableAttributes))) // Resolve them to their entitled FQNs and the actions available on each entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation) if err != nil { return nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } - p.logger.DebugContext(ctx, "evaluated subject mappings", slog.String("entity originalId", entityRepresentation.GetOriginalId()), slog.Any("entitled FQNs to actions", entitledFQNsToActions)) + l.DebugContext(ctx, "evaluated subject mappings", slog.Any("entitled FQNs to actions", entitledFQNsToActions)) decision := &Decision{ Access: true, @@ -217,10 +214,10 @@ func (p *PolicyDecisionPoint) GetDecision( decision.Results[idx] = *resourceDecision } - p.logger.DebugContext( + l.DebugContext( ctx, "decision results", - append(loggable, slog.Any("decision", decision))..., + slog.Any("decision", decision), ) return decision, nil @@ -232,28 +229,26 @@ func (p *PolicyDecisionPoint) GetEntitlements( optionalMatchedSubjectMappings []*policy.SubjectMapping, withComprehensiveHierarchy bool, ) ([]*authz.EntityEntitlements, error) { - loggable := []any{ - slog.Int("entities total", len(entityRepresentations)), - slog.Bool("with comprehensive hierarchy", withComprehensiveHierarchy), - } - err := validateEntityRepresentations(entityRepresentations) if err != nil { return nil, fmt.Errorf("invalid input parameters: %w", err) } + l := p.logger.With("withComprehensiveHierarchy", strconv.FormatBool(withComprehensiveHierarchy)) + l.DebugContext(ctx, "getting entitlements", slog.Int("entityRepresentationsCount", len(entityRepresentations))) + var entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue // Check entitlement only against the filtered matched subject mappings if provided if optionalMatchedSubjectMappings != nil { - p.logger.DebugContext(ctx, "getting entitlements with matched subject mappings", loggable...) + l.DebugContext(ctx, "filtering to provided matched subject mappings", slog.Int("matchedSubjectMappingsCount", len(optionalMatchedSubjectMappings))) entitleableAttributes, err = getFilteredEntitleableAttributes(optionalMatchedSubjectMappings, p.allEntitleableAttributesByValueFQN) if err != nil { return nil, fmt.Errorf("error filtering entitleable attributes from matched subject mappings: %w", err) } } else { // Otherwise, use all entitleable attributes - p.logger.DebugContext(ctx, "getting entitlements with all subject mappings (unmatched)", loggable...) + l.DebugContext(ctx, "getting entitlements with all subject mappings (unmatched)") entitleableAttributes = p.allEntitleableAttributesByValueFQN } @@ -262,6 +257,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( if err != nil { return nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } + l.DebugContext(ctx, "evaluated subject mappings", slog.Any("entitlementsByEntityID", entityIDsToFQNsToActions)) var result []*authz.EntityEntitlements for entityID, fqnsToActions := range entityIDsToFQNsToActions { @@ -292,10 +288,10 @@ func (p *PolicyDecisionPoint) GetEntitlements( ActionsPerAttributeValueFqn: actionsPerAttributeValueFqn, }) } - p.logger.DebugContext( + l.DebugContext( ctx, "entitlement results", - append(loggable, slog.Any("entitlements", result))..., + slog.Any("entitlements", result), ) return result, nil } From dd7fd44c761c1ad5fade80431986c089d2daaf18 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Thu, 22 May 2025 09:06:27 -0700 Subject: [PATCH 14/18] lower case resource attribute FQN at highest level in GetDecision flow --- service/internal/access/v2/evaluate.go | 6 +---- service/internal/access/v2/evaluate_test.go | 15 ----------- service/internal/access/v2/pdp.go | 5 +++- service/internal/access/v2/pdp_test.go | 29 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index b650ae32a5..77ef7d2721 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -70,11 +70,7 @@ func evaluateResourceAttributeValues( definitionFqnToValueFqns := make(map[string][]string) definitionsLookup := make(map[string]*policy.Attribute) - for idx, valueFQN := range resourceAttributeValues.GetFqns() { - // lowercase the value FQN to ensure case-insensitive matching - valueFQN = strings.ToLower(valueFQN) - resourceAttributeValues.Fqns[idx] = valueFQN - + for _, valueFQN := range resourceAttributeValues.GetFqns() { attributeAndValue, ok := accessibleAttributeValues[valueFQN] if !ok { return nil, fmt.Errorf("%w: %s", ErrFQNNotFound, valueFQN) diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index b106ab07c8..5e2dee2aab 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -681,21 +681,6 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { expectAccessible: true, expectError: false, }, - { - name: "all rules passing - non lower-cased FQNs", - resourceAttrs: &authz.Resource_AttributeValues{ - Fqns: []string{ - strings.ToUpper(levelMidFQN), - strings.ToUpper(deptFinanceFQN), - }, - }, - entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ - levelMidFQN: []*policy.Action{actionRead}, - deptFinanceFQN: []*policy.Action{actionRead}, - }, - expectAccessible: true, - expectError: false, - }, { name: "one rule failing", resourceAttrs: &authz.Resource_AttributeValues{ diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 55f534def4..c009b45e16 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -163,8 +163,11 @@ func (p *PolicyDecisionPoint) GetDecision( return nil, fmt.Errorf("registered resource value FQN not supported: %w", ErrInvalidResource) case *authz.Resource_AttributeValues_: - for _, valueFQN := range resource.GetAttributeValues().GetFqns() { + for idx, valueFQN := range resource.GetAttributeValues().GetFqns() { + // lowercase each resource attribute value FQN for case consistent map key lookups valueFQN = strings.ToLower(valueFQN) + resource.GetAttributeValues().Fqns[idx] = valueFQN + // If same value FQN more than once, skip if _, ok := decisionableAttributes[valueFQN]; ok { continue diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 0dfb806a30..6d0a6065e0 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -2,6 +2,7 @@ package access import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/suite" @@ -422,6 +423,34 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { } }) + s.Run("Multiple resources and entitled actions/attributes of varied casing - full access", func() { + entity := s.createEntityWithProps("test-user-1", map[string]interface{}{ + "clearance": "ts", + "department": "engineering", + }) + secretFQN := strings.ToUpper(testClassSecretFQN) + + resources := createResourcePerFqn(secretFQN, testDeptEngineeringFQN) + + decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 2) + + expectedResults := map[string]bool{ + secretFQN: true, + testDeptEngineeringFQN: true, + } + s.assertAllDecisionResults(decision, expectedResults) + for _, result := range decision.Results { + s.True(result.Passed, "All data rules should pass") + s.Len(result.DataRuleResults, 1) + s.Empty(result.DataRuleResults[0].EntitlementFailures) + } + }) + s.Run("Multiple resources and unentitled attributes - full denial", func() { entity := s.createEntityWithProps("test-user-2", map[string]interface{}{ "clearance": "confidential", // Not high enough for update on secret From 4d335155d3c97d166934e0ebe69221931973c13b Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Thu, 22 May 2025 10:52:45 -0700 Subject: [PATCH 15/18] merge duplicate actions performance --- service/internal/access/v2/helpers.go | 25 ++-- service/internal/access/v2/helpers_test.go | 135 ++++++++++++++++++++- service/internal/access/v2/pdp.go | 2 +- 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 7ad423a4f5..6c14462f02 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -100,6 +100,10 @@ func populateLowerValuesIfHierarchy( } lower := false + entitledActionsSet := make(map[string]*policy.Action) + for _, action := range entitledActions.GetActions() { + entitledActionsSet[action.GetName()] = action + } for _, value := range definition.GetValues() { if lower { alreadyEntitledActions, exists := entitledActionsPerAttributeValueFqn[value.GetFqn()] @@ -107,7 +111,7 @@ func populateLowerValuesIfHierarchy( entitledActionsPerAttributeValueFqn[value.GetFqn()] = entitledActions } else { // Ensure the actions are unique - mergedActions := mergeDeduplicatedActions(entitledActions.GetActions(), alreadyEntitledActions.GetActions()) + mergedActions := mergeDeduplicatedActions(entitledActionsSet, alreadyEntitledActions.GetActions()) merged := &authz.EntityEntitlements_ActionsList{ Actions: mergedActions, @@ -161,22 +165,17 @@ func populateHigherValuesIfHierarchy( } // Deduplicate and merge two lists of actions -func mergeDeduplicatedActions(existingActions []*policy.Action, actionsToMerge []*policy.Action) []*policy.Action { - actionMap := make(map[string]*policy.Action) - - // Add existing actions to the map - for _, action := range existingActions { - actionMap[action.GetName()] = action - } - +func mergeDeduplicatedActions(actionsSet map[string]*policy.Action, actionsToMerge ...[]*policy.Action) []*policy.Action { // Add or override with actions to merge - for _, action := range actionsToMerge { - actionMap[action.GetName()] = action + for _, actionList := range actionsToMerge { + for _, action := range actionList { + actionsSet[action.GetName()] = action + } } // Convert map back to slice - merged := make([]*policy.Action, 0, len(actionMap)) - for _, action := range actionMap { + merged := make([]*policy.Action, 0, len(actionsSet)) + for _, action := range actionsSet { merged = append(merged, action) } diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index 825aaa8246..cd77a52f8d 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -10,6 +10,7 @@ import ( "github.com/opentdf/platform/service/policy/actions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) // Updated assertions to include better validation of the retrieved definition @@ -374,7 +375,7 @@ func TestPopulateLowerValuesIfHierarchy(t *testing.T) { assert.Len(t, tt.expectedMapKeyFQNs, len(tt.actionsPerAttributeValueFqn), "Expected map to have %d keys, got %d", len(tt.expectedMapKeyFQNs), len(tt.actionsPerAttributeValueFqn)) for _, key := range tt.expectedMapKeyFQNs { assert.Contains(t, tt.actionsPerAttributeValueFqn, key, "Expected map to contain key %s", key) - assert.Equal(t, tt.entitledActions, tt.actionsPerAttributeValueFqn[key], "Expected map value for key %s to match", key) + assert.True(t, proto.Equal(tt.entitledActions, tt.actionsPerAttributeValueFqn[key]), "Expected map value for key %s to match", key) assert.Len(t, tt.actionsPerAttributeValueFqn[key].GetActions(), len(tt.entitledActions.GetActions()), "Expected map value for key %s to match", key) } } @@ -568,3 +569,135 @@ func TestPopulateHigherValuesIfHierarchy(t *testing.T) { assert.True(t, state) } } + +func TestMergeDeduplicatedActions(t *testing.T) { + // Define test actions + readAction := &policy.Action{Name: "read"} + writeAction := &policy.Action{Name: "write"} + updateAction := &policy.Action{Name: "update"} + deleteAction := &policy.Action{Name: "delete"} + + tests := []struct { + name string + initialSet map[string]*policy.Action + actionsToMerge [][]*policy.Action + expectedActions map[string]bool + }{ + { + name: "Empty initial set with single merge list", + initialSet: map[string]*policy.Action{}, + actionsToMerge: [][]*policy.Action{ + {readAction, writeAction}, + }, + expectedActions: map[string]bool{ + "read": true, + "write": true, + }, + }, + { + name: "Populated initial set with no merge", + initialSet: map[string]*policy.Action{ + "read": readAction, + "update": updateAction, + }, + actionsToMerge: [][]*policy.Action{}, + expectedActions: map[string]bool{ + "read": true, + "update": true, + }, + }, + { + name: "Populated initial set with non-overlapping merge", + initialSet: map[string]*policy.Action{ + "read": readAction, + "update": updateAction, + }, + actionsToMerge: [][]*policy.Action{ + {writeAction, deleteAction}, + }, + expectedActions: map[string]bool{ + "read": true, + "write": true, + "update": true, + "delete": true, + }, + }, + { + name: "Populated initial set with overlapping merge", + initialSet: map[string]*policy.Action{ + "read": readAction, + "update": updateAction, + }, + actionsToMerge: [][]*policy.Action{ + {readAction, writeAction}, + }, + expectedActions: map[string]bool{ + "read": true, + "write": true, + "update": true, + }, + }, + { + name: "Multiple merge lists with overlaps", + initialSet: map[string]*policy.Action{ + "read": readAction, + }, + actionsToMerge: [][]*policy.Action{ + {writeAction, updateAction}, + {deleteAction, writeAction}, + }, + expectedActions: map[string]bool{ + "read": true, + "write": true, + "update": true, + "delete": true, + }, + }, + { + name: "Nil action lists", + initialSet: map[string]*policy.Action{ + "read": readAction, + }, + actionsToMerge: [][]*policy.Action{ + nil, + {writeAction}, + nil, + }, + expectedActions: map[string]bool{ + "read": true, + "write": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a copy of the initial set to avoid modifying the test data + initialSet := make(map[string]*policy.Action) + for k, v := range tt.initialSet { + initialSet[k] = v + } + + // Convert actionsToMerge to variadic arguments + var actionsToMergeSlices [][]*policy.Action + for _, actionList := range tt.actionsToMerge { + actionsToMergeSlices = append(actionsToMergeSlices, actionList) + } + + // Call the function under test + result := mergeDeduplicatedActions(initialSet, actionsToMergeSlices...) + + assert.Len(t, result, len(tt.expectedActions)) + + // Check that all expected action names are present + resultNames := make(map[string]bool) + for _, action := range result { + resultNames[action.GetName()] = true + } + + for name := range tt.expectedActions { + assert.True(t, resultNames[name], "Expected action %s not found in result", name) + } + }) + } +} diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index c009b45e16..22d6118af6 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -269,7 +269,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( for valueFQN, actions := range fqnsToActions { // If already entitled (such as via a higher entitled comprehensive hierarchy attr value), merge with existing if alreadyEntitled, ok := actionsPerAttributeValueFqn[valueFQN]; ok { - actions = mergeDeduplicatedActions(alreadyEntitled.GetActions(), actions) + actions = mergeDeduplicatedActions(make(map[string]*policy.Action), actions, alreadyEntitled.GetActions()) } entitledActions := &authz.EntityEntitlements_ActionsList{ Actions: actions, From e1f92d88ba447364321cb7742f67c2f830d6efa3 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Thu, 22 May 2025 12:01:00 -0700 Subject: [PATCH 16/18] error assertions in tests --- service/internal/access/v2/helpers_test.go | 2 -- service/internal/access/v2/validators_test.go | 5 ----- 2 files changed, 7 deletions(-) diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index cd77a52f8d..4e5433ba9f 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -368,7 +368,6 @@ func TestPopulateLowerValuesIfHierarchy(t *testing.T) { err := populateLowerValuesIfHierarchy(tt.valueFQN, entitleableAttributes, tt.entitledActions, tt.actionsPerAttributeValueFqn) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) } else { require.NoError(t, err) @@ -513,7 +512,6 @@ func TestPopulateHigherValuesIfHierarchy(t *testing.T) { err := populateHigherValuesIfHierarchy(t.Context(), logger.CreateTestLogger(), tt.valueFQN, tt.definition, allValueFQNsToAttributeValues, decisionableAttributes) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) return } diff --git a/service/internal/access/v2/validators_test.go b/service/internal/access/v2/validators_test.go index 5c2ad9c669..ee3a7d2533 100644 --- a/service/internal/access/v2/validators_test.go +++ b/service/internal/access/v2/validators_test.go @@ -86,7 +86,6 @@ func TestValidateGetDecision(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := validateGetDecision(tt.entityRep, tt.action, tt.resources) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) } else { require.NoError(t, err) @@ -157,7 +156,6 @@ func TestValidateSubjectMapping(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := validateSubjectMapping(tt.subjectMapping) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) } else { require.NoError(t, err) @@ -264,7 +262,6 @@ func TestValidateAttribute(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := validateAttribute(tt.attribute) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) } else { require.NoError(t, err) @@ -305,7 +302,6 @@ func TestValidateEntityRepresentations(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := validateEntityRepresentations(tt.entityRepresentations) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) } else { require.NoError(t, err) @@ -417,7 +413,6 @@ func TestValidateGetResourceDecision(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := validateGetResourceDecision(tt.accessibleAttributeValues, tt.entitlements, tt.action, tt.resource) if tt.wantErr != nil { - require.Error(t, err) require.ErrorIs(t, err, tt.wantErr) } else { require.NoError(t, err) From 01007b6e39f03195bb3c2777d9fd4e6c20713925 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Thu, 22 May 2025 12:01:57 -0700 Subject: [PATCH 17/18] lint fix --- service/internal/access/v2/helpers_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index 4e5433ba9f..65088e6040 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -678,9 +678,7 @@ func TestMergeDeduplicatedActions(t *testing.T) { // Convert actionsToMerge to variadic arguments var actionsToMergeSlices [][]*policy.Action - for _, actionList := range tt.actionsToMerge { - actionsToMergeSlices = append(actionsToMergeSlices, actionList) - } + actionsToMergeSlices = append(actionsToMergeSlices, tt.actionsToMerge...) // Call the function under test result := mergeDeduplicatedActions(initialSet, actionsToMergeSlices...) From 8566fc4c651999113cffd7b41c5ba59179f7c632 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Thu, 22 May 2025 12:07:27 -0700 Subject: [PATCH 18/18] improve validator logic per suggestion --- service/internal/access/v2/validators.go | 5 +++-- service/internal/access/v2/validators_test.go | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index e1de639c3b..7e21b1895e 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -3,6 +3,7 @@ package access import ( "errors" "fmt" + "strings" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" @@ -86,8 +87,8 @@ func validateAttribute(attribute *policy.Attribute) error { if value == nil { return fmt.Errorf("attribute value is nil: %w", ErrInvalidAttributeDefinition) } - if value.GetFqn() == "" { - return fmt.Errorf("attribute value FQN is empty: %w", ErrInvalidAttributeDefinition) + if !strings.HasPrefix(value.GetFqn(), attribute.GetFqn()) { + return fmt.Errorf("attribute value FQN must be of definition FQN: %w", ErrInvalidAttributeDefinition) } } if attribute.GetRule() == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED { diff --git a/service/internal/access/v2/validators_test.go b/service/internal/access/v2/validators_test.go index ee3a7d2533..16408bcb95 100644 --- a/service/internal/access/v2/validators_test.go +++ b/service/internal/access/v2/validators_test.go @@ -256,6 +256,19 @@ func TestValidateAttribute(t *testing.T) { }, wantErr: ErrInvalidAttributeDefinition, }, + { + name: "Attribute value FQN does not match attribute FQN", + attribute: &policy.Attribute{ + Fqn: "https://example.org/attr/name", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{ + { + Fqn: "https://example.org/attr/other/value/public", + }, + }, + }, + wantErr: ErrInvalidAttributeDefinition, + }, } for _, tt := range tests {