diff --git a/service/integration/registered_resources_test.go b/service/integration/registered_resources_test.go index bd41b797bb..92b97c15aa 100644 --- a/service/integration/registered_resources_test.go +++ b/service/integration/registered_resources_test.go @@ -246,6 +246,92 @@ func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_NoPagination_Suc s.Equal(2, foundCount) } +func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_RegResValuesContainActionAttributeValues() { + // Create a registered resource with values that have action attribute values + newRegRes, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_list_reg_res_with_action_attr_values", + }) + s.Require().NoError(err) + s.NotNil(newRegRes) + regResID := newRegRes.GetId() + + val1, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: regResID, + Value: "test_value_1", + ActionAttributeValues: []*registeredresources.ActionAttributeValue{ + { + ActionIdentifier: ®isteredresources.ActionAttributeValue_ActionName{ + ActionName: actions.ActionNameCreate, + }, + AttributeValueIdentifier: ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: "https://example.com/attr/attr1/value/value1", + }, + }, + }, + }) + s.Require().NoError(err) + s.NotNil(val1) + + val2, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: regResID, + Value: "test_value_2", + ActionAttributeValues: []*registeredresources.ActionAttributeValue{ + { + ActionIdentifier: ®isteredresources.ActionAttributeValue_ActionName{ + ActionName: actions.ActionNameUpdate, + }, + AttributeValueIdentifier: ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: "https://example.com/attr/attr2/value/value2", + }, + }, + }, + }) + s.Require().NoError(err) + s.NotNil(val2) + + // List registered resources and check if values contain action attribute values + list, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{}) + s.Require().NoError(err) + s.NotNil(list) + + foundRegRes := false + foundVal1 := false + foundVal2 := false + for _, r := range list.GetResources() { + if r.GetId() == regResID { + s.Equal("test_list_reg_res_with_action_attr_values", r.GetName()) + values := r.GetValues() + s.Require().Len(values, 2) + foundRegRes = true + + // Check if action attribute values are present in the values + for _, v := range values { + if v.GetId() == val1.GetId() { + foundVal1 = true + actionAttrValues := v.GetActionAttributeValues() + s.Require().NotEmpty(actionAttrValues) + for _, aav := range actionAttrValues { + s.NotNil(aav.GetAction()) + s.NotNil(aav.GetAttributeValue()) + } + } + if v.GetId() == val2.GetId() { + foundVal2 = true + actionAttrValues := v.GetActionAttributeValues() + s.Require().NotEmpty(actionAttrValues) + for _, aav := range actionAttrValues { + s.NotNil(aav.GetAction()) + s.NotNil(aav.GetAttributeValue()) + } + } + } + } + } + s.True(foundRegRes, "Registered resource not found in list") + s.True(foundVal1, "Value 1 not found in registered resource values") + s.True(foundVal2, "Value 2 not found in registered resource values") +} + func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_Limit_Succeeds() { var limit int32 = 1 list, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{ diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index c6f1dabfbd..3e9d9c1df9 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" authz "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -29,6 +30,7 @@ func getResourceDecision( ctx context.Context, l *logger.Logger, accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + accessibleRegisteredResourceValues map[string]*policy.RegisteredResourceValue, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resource *authz.Resource, @@ -43,16 +45,44 @@ func getResourceDecision( slog.Any("resource", resource.GetResource()), ) + var ( + resourceID = resource.GetEphemeralId() + resourceAttributeValues *authz.Resource_AttributeValues + ) + switch resource.GetResource().(type) { - // TODO: handle registered resources case *authz.Resource_RegisteredResourceValueFqn: - return nil, fmt.Errorf("registered resources not supported yet: %w", ErrInvalidResource) + regResValueFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) + regResValue, found := accessibleRegisteredResourceValues[regResValueFQN] + if !found { + return nil, fmt.Errorf("%w: %s", ErrFQNNotFound, regResValueFQN) + } + + resourceAttributeValues = &authz.Resource_AttributeValues{ + Fqns: make([]string, 0), + } + for _, aav := range regResValue.GetActionAttributeValues() { + // TODO: DSPX-1295 - revisit this logic -- reg res' are different from attr values since they can be both entity and resource + // and are tied to actions and attribute values + // + // if aav.GetAction().GetName() != action.GetName() { + // continue + // } + + aavAttrValueFQN := aav.GetAttributeValue().GetFqn() + if !slices.Contains(resourceAttributeValues.GetFqns(), aavAttrValueFQN) { + resourceAttributeValues.Fqns = append(resourceAttributeValues.Fqns, aavAttrValueFQN) + } + } + case *authz.Resource_AttributeValues_: - return evaluateResourceAttributeValues(ctx, l, resource.GetAttributeValues(), resource.GetEphemeralId(), action, entitlements, accessibleAttributeValues) + resourceAttributeValues = resource.GetAttributeValues() default: return nil, fmt.Errorf("unsupported resource type: %w", ErrInvalidResource) } + + return evaluateResourceAttributeValues(ctx, l, resourceAttributeValues, resourceID, action, entitlements, accessibleAttributeValues) } // evaluateResourceAttributeValues evaluates a list of attribute values against the action and entitlements diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 5e2dee2aab..e0174b0d1d 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -54,10 +54,11 @@ type EvaluateTestSuite struct { action *policy.Action // Common test data - hierarchicalClassAttr *policy.Attribute - allOfProjectAttr *policy.Attribute - anyOfDepartmentAttr *policy.Attribute - accessibleAttrValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + hierarchicalClassAttr *policy.Attribute + allOfProjectAttr *policy.Attribute + anyOfDepartmentAttr *policy.Attribute + accessibleAttrValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue + accessibleRegisteredResourceValues map[string]*policy.RegisteredResourceValue } func (s *EvaluateTestSuite) SetupTest() { @@ -152,6 +153,10 @@ func (s *EvaluateTestSuite) SetupTest() { Value: &policy.Value{Fqn: projectFantasicFourFQN}, }, } + + // Setup accessible registered resource values map + // TODO: DSPX-1295 + s.accessibleRegisteredResourceValues = map[string]*policy.RegisteredResourceValue{} } func TestEvaluateSuite(t *testing.T) { @@ -780,6 +785,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { s.T().Context(), s.logger, s.accessibleAttrValues, + s.accessibleRegisteredResourceValues, tc.entitlements, s.action, tc.resource, diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 27cac3ce0c..69342cadd7 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "log/slog" + "strconv" + "strings" "github.com/opentdf/platform/lib/identifier" authz "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -186,3 +188,80 @@ func mergeDeduplicatedActions(actionsSet map[string]*policy.Action, actionsToMer return merged } + +func getResourceDecisionableAttributes( + ctx context.Context, + logger *logger.Logger, + accessibleRegisteredResourceValues map[string]*policy.RegisteredResourceValue, + entitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + // action *policy.Action, + resources []*authz.Resource, +) (map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, error) { + var ( + decisionableAttributes = make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) + attrValueFQNs = make([]string, 0) + ) + + // Parse attribute value FQNs from various resource types + 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: + regResValueFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) + regResValue, found := accessibleRegisteredResourceValues[regResValueFQN] + if !found { + return nil, fmt.Errorf("resource registered resource value FQN not found in memory [%s]: %w", regResValueFQN, ErrInvalidResource) + } + + for _, aav := range regResValue.GetActionAttributeValues() { + // TODO: DSPX-1295 - revisit this logic bc it is causing failures for attributes with missing actions + // slog.Info("processing action attribute value", slog.Any("aav", aav)) + // aavAction := aav.GetAction() + // if aavAction.GetName() != action.GetName() { + // logger.DebugContext(ctx, "skipping action not matching Decision Request action", slog.String("action", aavAction.GetName())) + // continue + // } + + attrValueFQNs = append(attrValueFQNs, aav.GetAttributeValue().GetFqn()) + } + + case *authz.Resource_AttributeValues_: + for idx, attrValueFQN := range resource.GetAttributeValues().GetFqns() { + // lowercase each resource attribute value FQN for case consistent map key lookups + attrValueFQN = strings.ToLower(attrValueFQN) + resource.GetAttributeValues().Fqns[idx] = attrValueFQN + + attrValueFQNs = append(attrValueFQNs, attrValueFQN) + } + + default: + // default should never happen as we validate above + return nil, fmt.Errorf("invalid resource type [%T]: %w", resource.GetResource(), ErrInvalidResource) + } + } + + // determine decisionable attributes based on the attribute value FQNs + for _, attrValueFQN := range attrValueFQNs { + // If same value FQN more than once, skip (dedupe) + if _, ok := decisionableAttributes[attrValueFQN]; ok { + continue + } + + attributeAndValue, ok := entitleableAttributesByValueFQN[attrValueFQN] + if !ok { + return nil, fmt.Errorf("resource attribute value FQN not found in memory [%s]: %w", attrValueFQN, ErrInvalidResource) + } + + decisionableAttributes[attrValueFQN] = attributeAndValue + err := populateHigherValuesIfHierarchy(ctx, logger, attrValueFQN, attributeAndValue.GetAttribute(), entitleableAttributesByValueFQN, decisionableAttributes) + if err != nil { + return nil, fmt.Errorf("error populating higher hierarchy attribute values: %w", err) + } + } + + return decisionableAttributes, nil +} diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index e265a69ac8..acfae78b0b 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -105,10 +105,17 @@ func (p *JustInTimePDP) GetDecision( case *authzV2.EntityIdentifier_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") - return nil, false, errors.New("registered resources not yet implemented") + regResValueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) + // registered resources do not have entity representations, so only one decision to make and we can skip the remaining logic + decision, err := p.pdp.GetDecisionRegisteredResource(ctx, regResValueFQN, action, resources) + if err != nil { + return nil, false, fmt.Errorf("failed to get decision for registered resource value FQN [%s]: %w", regResValueFQN, err) + } + if decision == nil { + return nil, false, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN) + } + return []*Decision{decision}, decision.Access, nil default: return nil, false, ErrInvalidEntityType @@ -160,10 +167,10 @@ 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") - valueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) + p.logger.DebugContext(ctx, "getting entitlements - resolving registered resource value FQN") + regResValueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) // registered resources do not have entity representations, so we can skip the remaining logic - return p.pdp.GetEntitlementsRegisteredResource(ctx, valueFQN, withComprehensiveHierarchy) + return p.pdp.GetEntitlementsRegisteredResource(ctx, regResValueFQN, withComprehensiveHierarchy) default: return nil, fmt.Errorf("entity type %T: %w", entityIdentifier.GetIdentifier(), ErrInvalidEntityType) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 7872a278a4..30235e86dd 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -7,7 +7,6 @@ import ( "log/slog" "slices" "strconv" - "strings" "github.com/opentdf/platform/lib/identifier" authz "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -141,6 +140,10 @@ func NewPolicyDecisionPoint( rrName := rr.GetName() for _, v := range rr.GetValues() { + if err := validateRegisteredResourceValue(v); err != nil { + return nil, fmt.Errorf("invalid registered resource value: %w", err) + } + fullyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ Name: rrName, Value: v.GetValue(), @@ -164,7 +167,7 @@ func (p *PolicyDecisionPoint) GetDecision( action *policy.Action, resources []*authz.Resource, ) (*Decision, error) { - l := p.logger.With("entityID", entityRepresentation.GetOriginalId()) + l := p.logger.With("entity_id", entityRepresentation.GetOriginalId()) l = l.With("action", action.GetName()) l.DebugContext(ctx, "getting decision", slog.Int("resources_count", len(resources))) @@ -173,46 +176,9 @@ func (p *PolicyDecisionPoint) GetDecision( } // 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) { - // TODO: handle gathering decisionable attributes of registered resources - case *authz.Resource_RegisteredResourceValueFqn: - return nil, fmt.Errorf("registered resource value FQN not supported: %w", ErrInvalidResource) - - case *authz.Resource_AttributeValues_: - 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 - } - - attributeAndValue, ok := p.allEntitleableAttributesByValueFQN[valueFQN] - if !ok { - return nil, fmt.Errorf("%w [%s]: %w", ErrFQNNotFound, valueFQN, ErrInvalidResource) - } - - decisionableAttributes[valueFQN] = attributeAndValue - err := populateHigherValuesIfHierarchy(ctx, l, valueFQN, attributeAndValue.GetAttribute(), p.allEntitleableAttributesByValueFQN, decisionableAttributes) - if err != nil { - return nil, fmt.Errorf("error populating higher hierarchy attribute values: %w", err) - } - } - - default: - // default should never happen as we validate above - return nil, fmt.Errorf("invalid resource type [%T]: %w", resource.GetResource(), ErrInvalidResource) - } + decisionableAttributes, err := getResourceDecisionableAttributes(ctx, l, p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN /* action, */, resources) + if err != nil { + return nil, fmt.Errorf("error getting decisionable attributes: %w", err) } l.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable_attribute_values_count", len(decisionableAttributes))) @@ -229,7 +195,7 @@ func (p *PolicyDecisionPoint) GetDecision( } for idx, resource := range resources { - resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, entitledFQNsToActions, action, resource) + resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource) if err != nil || resourceDecision == nil { return nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } @@ -242,8 +208,8 @@ func (p *PolicyDecisionPoint) GetDecision( ctx, "resourceDecision result", slog.Bool("passed", resourceDecision.Passed), - slog.String("resourceID", resourceDecision.ResourceID), - slog.Int("dataRuleResultsCount", len(resourceDecision.DataRuleResults)), + slog.String("resource_id", resourceDecision.ResourceID), + slog.Int("data_rule_results_count", len(resourceDecision.DataRuleResults)), ) decision.Results[idx] = *resourceDecision } @@ -264,6 +230,83 @@ func (p *PolicyDecisionPoint) GetDecision( return decision, nil } +func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( + ctx context.Context, + entityRegisteredResourceValueFQN string, + action *policy.Action, + resources []*authz.Resource, +) (*Decision, error) { + l := p.logger.With("entity_registered_resource_value_fqn", entityRegisteredResourceValueFQN) + l = l.With("action", action.GetName()) + l.DebugContext(ctx, "getting decision", slog.Int("resources_count", len(resources))) + + if err := validateGetDecisionRegisteredResource(entityRegisteredResourceValueFQN, action, resources); err != nil { + return nil, err + } + + entityRegisteredResourceValue, ok := p.allRegisteredResourceValuesByFQN[entityRegisteredResourceValueFQN] + if !ok { + return nil, fmt.Errorf("registered resource value FQN not found in memory [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) + } + + // Filter all attributes down to only those that relevant to the entitlement decisioning of these specific resources + decisionableAttributes, err := getResourceDecisionableAttributes(ctx, l, p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN /*action, */, resources) + if err != nil { + return nil, fmt.Errorf("error getting decisionable attributes: %w", err) + } + l.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable_attribute_values_count", len(decisionableAttributes))) + + entitledFQNsToActions := make(map[string][]*policy.Action) + for _, aav := range entityRegisteredResourceValue.GetActionAttributeValues() { + aavAction := aav.GetAction() + if action.GetName() != aavAction.GetName() { + l.DebugContext(ctx, "skipping action not matching Decision Request action", slog.String("action_name", aavAction.GetName())) + continue + } + + attrVal := aav.GetAttributeValue() + attrValFQN := attrVal.GetFqn() + actionsList, actionsAreOK := entitledFQNsToActions[attrValFQN] + if !actionsAreOK { + actionsList = make([]*policy.Action, 0) + } + + if !slices.ContainsFunc(actionsList, func(a *policy.Action) bool { + return a.GetName() == aavAction.GetName() + }) { + actionsList = append(actionsList, aavAction) + } + + entitledFQNsToActions[attrValFQN] = actionsList + } + + decision := &Decision{ + Access: true, + Results: make([]ResourceDecision, len(resources)), + } + + for idx, resource := range resources { + resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource) + if err != nil || resourceDecision == nil { + return nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) + } + if !resourceDecision.Passed { + decision.Access = false + } + + l.DebugContext( + ctx, + "resourceDecision result", + slog.Bool("passed", resourceDecision.Passed), + slog.String("resource_id", resourceDecision.ResourceID), + slog.Int("data_rule_results_count", len(resourceDecision.DataRuleResults)), + ) + decision.Results[idx] = *resourceDecision + } + + return decision, nil +} + func (p *PolicyDecisionPoint) GetEntitlements( ctx context.Context, entityRepresentations []*entityresolutionV2.EntityRepresentation, @@ -275,7 +318,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( return nil, fmt.Errorf("invalid input parameters: %w", err) } - l := p.logger.With("withComprehensiveHierarchy", strconv.FormatBool(withComprehensiveHierarchy)) + l := p.logger.With("with_comprehensive_hierarchy", strconv.FormatBool(withComprehensiveHierarchy)) l.DebugContext(ctx, "getting entitlements", slog.Int("entity_representations_count", len(entityRepresentations))) var entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue @@ -342,16 +385,16 @@ func (p *PolicyDecisionPoint) GetEntitlementsRegisteredResource( registeredResourceValueFQN string, withComprehensiveHierarchy bool, ) ([]*authz.EntityEntitlements, error) { - l := p.logger.With("withComprehensiveHierarchy", strconv.FormatBool(withComprehensiveHierarchy)) + l := p.logger.With("with_comprehensive_hierarchy", strconv.FormatBool(withComprehensiveHierarchy)) l.DebugContext(ctx, "getting entitlements for registered resource value", slog.String("fqn", registeredResourceValueFQN)) if _, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN); err != nil { return nil, err } - registeredResourceValue := p.allRegisteredResourceValuesByFQN[registeredResourceValueFQN] - if err := validateRegisteredResourceValue(registeredResourceValue); err != nil { - return nil, err + registeredResourceValue, ok := p.allRegisteredResourceValuesByFQN[registeredResourceValueFQN] + if !ok { + return nil, fmt.Errorf("registered resource value FQN not found in memory [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) } actionsPerAttributeValueFqn := make(map[string]*authz.EntityEntitlements_ActionsList) @@ -361,8 +404,8 @@ func (p *PolicyDecisionPoint) GetEntitlementsRegisteredResource( attrVal := aav.GetAttributeValue() attrValFQN := attrVal.GetFqn() - actionsList, ok := actionsPerAttributeValueFqn[attrValFQN] - if !ok { + actionsList, actionsAreOK := actionsPerAttributeValueFqn[attrValFQN] + if !actionsAreOK { actionsList = &authz.EntityEntitlements_ActionsList{ Actions: make([]*policy.Action, 0), } diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 3164ba416b..0224e9b1ae 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -86,9 +86,45 @@ var ( testPlatformCloudFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "cloud") testPlatformOnPremFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "onprem") testPlatformHybridFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "hybrid") + + // Registered resource value FQNs (TODO: DSPX-1295 - remove) + testNetworkPrivateFQN = createRegisteredResourceValueFQN("network", "private") + testNetworkPublicFQN = createRegisteredResourceValueFQN("network", "public") + // testNetworkConfidentialFQN = createRegisteredResourceValueFQN("network", "confidential") + // testNetworkAlphaFQN = createRegisteredResourceValueFQN("network", "alpha") +) + +// registered resource value FQNs using identifier package +var ( + // Classification values + // testClassTopSecretRegResFQN = createRegisteredResourceValueFQN("classification", "topsecret") + testClassSecretRegResFQN = createRegisteredResourceValueFQN("classification", "secret") + testClassConfidentialRegResFQN = createRegisteredResourceValueFQN("classification", "confidential") + // testClassPublicRegResFQN = createRegisteredResourceValueFQN("classification", "public") + + // Department values + // testDeptRnDRegResFQN = createRegisteredResourceValueFQN("department", "rnd") + testDeptEngineeringRegResFQN = createRegisteredResourceValueFQN("department", "engineering") + // testDeptSalesRegResFQN = createRegisteredResourceValueFQN("department", "sales") + testDeptFinanceRegResFQN = createRegisteredResourceValueFQN("department", "finance") + + // Country values + // testCountryUSARegResFQN = createRegisteredResourceValueFQN("country", "usa") + // testCountryUKRegResFQN = createRegisteredResourceValueFQN("country", "uk") + + // Project values in secondary namespace + testProjectAlphaRegResFQN = createRegisteredResourceValueFQN("project", "alpha") + // testProjectBetaRegResFQN = createRegisteredResourceValueFQN("project", "beta") + // testProjectGammaRegResFQN = createRegisteredResourceValueFQN("project", "gamma") + + // Platform values in secondary namespace + // testPlatformCloudRegResFQN = createRegisteredResourceValueFQN("platform", "cloud") + // testPlatformOnPremRegResFQN = createRegisteredResourceValueFQN("platform", "onprem") + // testPlatformHybridRegResFQN = createRegisteredResourceValueFQN("platform", "hybrid") ) // Registered resource value FQNs using identifier package +// TODO: DSPX-1295 - remove these and use the other ones above var ( regResValNoActionAttrValFQN string regResValSingleActionAttrValFQN string @@ -137,6 +173,14 @@ type PDPTestSuite struct { analystEntity *entityresolutionV2.EntityRepresentation // Test registered resources + classificationRegRes *policy.RegisteredResource + deptRegRes *policy.RegisteredResource + networkRegRes *policy.RegisteredResource // TODO: DSPX-1295 - remove this and use the others that match test attributes + countryRegRes *policy.RegisteredResource + projectRegRes *policy.RegisteredResource + platformRegRes *policy.RegisteredResource + + // Test registered resources (TODO: DSPX-1295 - remove these and use the ones above) regRes *policy.RegisteredResource regResValNoActionAttrVal *policy.RegisteredResourceValue regResValSingleActionAttrVal *policy.RegisteredResourceValue @@ -336,6 +380,231 @@ func (s *PDPTestSuite) SetupTest() { []string{"cloud"}, ) + // Initialize registered resources + s.fixtures.classificationRegRes = &policy.RegisteredResource{ + Name: "classification", + Values: []*policy.RegisteredResourceValue{ + { + Value: "topsecret", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassTopSecretFQN, + Value: "topsecret", + }, + }, + }, + }, + { + Value: "secret", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassSecretFQN, + Value: "secret", + }, + }, + { + Action: testActionUpdate, + AttributeValue: &policy.Value{ + Fqn: testClassSecretFQN, + Value: "secret", + }, + }, + }, + }, + { + Value: "confidential", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + }, + }, + }, + { + Value: "public", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + }, + }, + }, + } + + s.fixtures.deptRegRes = &policy.RegisteredResource{ + Name: "department", + Values: []*policy.RegisteredResourceValue{ + { + Value: "rnd", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptRnDFQN, + Value: "rnd", + }, + }, + { + Action: testActionUpdate, + AttributeValue: &policy.Value{ + Fqn: testDeptRnDFQN, + Value: "rnd", + }, + }, + }, + }, + { + Value: "engineering", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptEngineeringFQN, + Value: "engineering", + }, + }, + { + Action: testActionCreate, + AttributeValue: &policy.Value{ + Fqn: testDeptEngineeringFQN, + Value: "engineering", + }, + }, + }, + }, + { + Value: "sales", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{}, + }, + { + Value: "finance", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptFinanceFQN, + Value: "finance", + }, + }, + { + Action: testActionUpdate, + AttributeValue: &policy.Value{ + Fqn: testDeptFinanceFQN, + Value: "finance", + }, + }, + }, + }, + }, + } + + s.fixtures.countryRegRes = &policy.RegisteredResource{ + Name: "country", + Values: []*policy.RegisteredResourceValue{ + { + Value: "usa", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testCountryUSAFQN, + Value: "usa", + }, + }, + }, + }, + { + Value: "uk", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testCountryUKFQN, + Value: "uk", + }, + }, + }, + }, + }, + } + + s.fixtures.projectRegRes = &policy.RegisteredResource{ + Name: "project", + Values: []*policy.RegisteredResourceValue{ + { + Value: "alpha", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testProjectAlphaFQN, + Value: "alpha", + }, + }, + { + Action: testActionCreate, + AttributeValue: &policy.Value{ + Fqn: testProjectAlphaFQN, + Value: "alpha", + }, + }, + }, + }, + { + Value: "beta", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{}, + }, + { + Value: "gamma", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{}, + }, + }, + } + + s.fixtures.platformRegRes = &policy.RegisteredResource{ + Name: "platform", + Values: []*policy.RegisteredResourceValue{ + { + Value: "cloud", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testPlatformCloudFQN, + Value: "cloud", + }, + }, + { + Action: testActionDelete, + AttributeValue: &policy.Value{ + Fqn: testPlatformCloudFQN, + Value: "cloud", + }, + }, + }, + }, + { + Value: "onprem", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{}, + }, + { + Value: "hybrid", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{}, + }, + }, + } + // Initialize standard test entities s.fixtures.adminEntity = s.createEntityWithProps("admin-entity", map[string]interface{}{ "clearance": "secret", @@ -354,6 +623,82 @@ func (s *PDPTestSuite) SetupTest() { }) // Initialize test registered resources + s.fixtures.networkRegRes = &policy.RegisteredResource{ + Name: "network", + Values: []*policy.RegisteredResourceValue{ + { + Value: "private", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassSecretFQN, + Value: "secret", + }, + }, + { + Action: testActionUpdate, + AttributeValue: &policy.Value{ + Fqn: testClassSecretFQN, + Value: "secret", + }, + }, + }, + }, + { + Value: "public", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + }, + }, + { + Value: "confidential", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + }, + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptFinanceFQN, + Value: "finance", + }, + }, + }, + }, + { + Value: "alpha", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testProjectAlphaFQN, + Value: "alpha", + }, + }, + { + Action: testActionCreate, + AttributeValue: &policy.Value{ + Fqn: testProjectAlphaFQN, + Value: "alpha", + }, + }, + }, + }, + }, + } + + // Initialize test registered resources (TODO: DSPX-1295: replace with above real use cases) regResValNoActionAttrVal := &policy.RegisteredResourceValue{ Value: "no-action-attr-val", ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{}, @@ -549,8 +894,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { s.T().Context(), s.logger, []*policy.Attribute{f.classificationAttr, f.departmentAttr}, - []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping, f.confidentialMapping, f.engineeringMapping, f.financeMapping}, - []*policy.RegisteredResource{}, + []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping, f.confidentialMapping, f.publicMapping, f.engineeringMapping, f.financeMapping}, + []*policy.RegisteredResource{f.classificationRegRes, f.deptRegRes}, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -561,18 +906,23 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "engineering", }) - resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN) + resources := createResourcePerFqn( + testClassSecretFQN, testDeptEngineeringFQN, + testClassSecretRegResFQN, testDeptEngineeringRegResFQN, + ) 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) + s.Len(decision.Results, 4) expectedResults := map[string]bool{ - testClassSecretFQN: true, - testDeptEngineeringFQN: true, + testClassSecretFQN: true, + testDeptEngineeringFQN: true, + testClassSecretRegResFQN: true, + testDeptEngineeringRegResFQN: true, } s.assertAllDecisionResults(decision, expectedResults) for _, result := range decision.Results { @@ -588,19 +938,22 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "engineering", }) secretFQN := strings.ToUpper(testClassSecretFQN) + secretRegResFQN := strings.ToUpper(testClassSecretRegResFQN) - resources := createResourcePerFqn(secretFQN, testDeptEngineeringFQN) + resources := createResourcePerFqn(secretFQN, testDeptEngineeringFQN, secretRegResFQN, testDeptEngineeringRegResFQN) 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) + s.Len(decision.Results, 4) expectedResults := map[string]bool{ - secretFQN: true, - testDeptEngineeringFQN: true, + secretFQN: true, + testDeptEngineeringFQN: true, + secretRegResFQN: true, + testDeptEngineeringRegResFQN: true, } s.assertAllDecisionResults(decision, expectedResults) for _, result := range decision.Results { @@ -616,18 +969,23 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "finance", // Not engineering }) - resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN) + resources := createResourcePerFqn( + testClassSecretFQN, testDeptEngineeringFQN, + testClassSecretRegResFQN, testDeptEngineeringRegResFQN, + ) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 2) + s.Len(decision.Results, 4) expectedResults := map[string]bool{ - testClassSecretFQN: false, - testDeptEngineeringFQN: false, + testClassSecretFQN: false, + testDeptEngineeringFQN: false, + testClassSecretRegResFQN: false, + testDeptEngineeringRegResFQN: false, } s.assertAllDecisionResults(decision, expectedResults) @@ -644,7 +1002,9 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "engineering", }) - resources := createResourcePerFqn(testDeptEngineeringFQN, testClassSecretFQN) + resources := createResourcePerFqn( + testDeptEngineeringFQN, testClassSecretFQN, + testDeptEngineeringRegResFQN, testClassSecretRegResFQN) // Get decision for delete action (not allowed by either attribute's subject mappings) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) @@ -652,11 +1012,13 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) - s.Len(decision.Results, 2) + s.Len(decision.Results, 4) expectedResults := map[string]bool{ - testDeptEngineeringFQN: false, - testClassSecretFQN: false, + testDeptEngineeringFQN: false, + testClassSecretFQN: false, + testDeptEngineeringRegResFQN: false, + testClassSecretRegResFQN: false, } s.assertAllDecisionResults(decision, expectedResults) }) @@ -667,18 +1029,23 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "engineering", // not finance }) - resources := createResourcePerFqn(testClassSecretFQN, testDeptFinanceFQN) + resources := createResourcePerFqn( + testClassSecretFQN, testDeptFinanceFQN, + testClassSecretRegResFQN, testDeptFinanceRegResFQN, + ) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) // False because one resource is denied - s.Len(decision.Results, 2) + s.Len(decision.Results, 4) expectedResults := map[string]bool{ - testClassSecretFQN: true, - testDeptFinanceFQN: false, + testClassSecretFQN: true, + testDeptFinanceFQN: false, + testClassSecretRegResFQN: true, + testDeptFinanceRegResFQN: false, } s.assertAllDecisionResults(decision, expectedResults) @@ -716,6 +1083,31 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []string{"confidential"}, ) + printConfidentialRegRes := &policy.RegisteredResource{ + Name: "classification-print", + Values: []*policy.RegisteredResourceValue{ + { + Value: "confidential-print", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + }, + { + Action: testActionPrint, + AttributeValue: &policy.Value{ + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + }, + }, + }, + }, + } + // Create a mapping with a comprehensive set of actions instead of using a wildcard allActionsPublicMapping := createSimpleSubjectMapping( testClassPublicFQN, @@ -728,6 +1120,73 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []string{"public"}, ) + allActionsPublicRegRes := &policy.RegisteredResource{ + Name: "classification-all-actions", + Values: []*policy.RegisteredResourceValue{ + { + Value: "public-all-actions", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionCreate, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionUpdate, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionDelete, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionPrint, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionView, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionList, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + { + Action: testActionSearch, + AttributeValue: &policy.Value{ + Fqn: testClassPublicFQN, + Value: "public", + }, + }, + }, + }, + }, + } + // Create a view mapping for Project Alpha with view being a parent action of read and list viewProjectAlphaMapping := createSimpleSubjectMapping( testProjectAlphaFQN, @@ -737,6 +1196,24 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []string{"alpha"}, ) + viewProjectAlphaRegRes := &policy.RegisteredResource{ + Name: "project-view", + Values: []*policy.RegisteredResourceValue{ + { + Value: "alpha-view", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionView, + AttributeValue: &policy.Value{ + Fqn: testProjectAlphaFQN, + Value: "alpha", + }, + }, + }, + }, + }, + } + // Create a PDP with relevant attributes and mappings pdp, err := NewPolicyDecisionPoint( s.T().Context(), @@ -746,7 +1223,10 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { f.secretMapping, f.topSecretMapping, printConfidentialMapping, allActionsPublicMapping, f.engineeringMapping, f.financeMapping, viewProjectAlphaMapping, }, - []*policy.RegisteredResource{}, + []*policy.RegisteredResource{ + f.classificationRegRes, f.deptRegRes, f.projectRegRes, + printConfidentialRegRes, allActionsPublicRegRes, viewProjectAlphaRegRes, + }, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -758,22 +1238,22 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { }) // Resource to evaluate - resources := createResourcePerFqn(testClassSecretFQN) + resources := createResourcePerFqn(testClassSecretFQN, testClassSecretRegResFQN) decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, resources) - // Read shuld pass + // Read should 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.Len(decision.Results, 2) // 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.Len(decision.Results, 2) }) s.Run("Scenario 2: User has overlapping action sets", func() { @@ -783,35 +1263,37 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { "department": "finance", }) - combinedResource := createResource("combined-attr-resource", testClassConfidentialFQN, testDeptFinanceFQN) + combinedResource := createAttributeValueResource("combined-attr-resource", testClassConfidentialFQN, testDeptFinanceFQN) + testClassConfidentialRegResResource := createRegisteredResource(testClassConfidentialRegResFQN, testClassConfidentialRegResFQN) + testDeptFinanceRegResResource := createRegisteredResource(testDeptFinanceRegResFQN, testDeptFinanceRegResFQN) - // Test read access - should be allowed by both attributes - decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource}) + // Test read access - should be allowed by all attributes + decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) s.True(decision.Access) - s.Len(decision.Results, 1) + s.Len(decision.Results, 3) // Test create access - should be denied (confidential doesn't allow it) - decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource}) + decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) 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}) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionPrint, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) 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}) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) 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}) + decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.Access) @@ -822,7 +1304,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { "project": "alpha", }) - resources := createResourcePerFqn(testProjectAlphaFQN) + resources := createResourcePerFqn(testProjectAlphaFQN, testProjectAlphaRegResFQN) // Test view access - should be allowed decision, err := pdp.GetDecision(s.T().Context(), entity, testActionView, resources) @@ -852,13 +1334,30 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { ".properties.clearance", []string{"restricted"}, ) + restrictedRegRes := &policy.RegisteredResource{ + Name: "confidential-restricted", + Values: []*policy.RegisteredResourceValue{ + { + Value: "restricted-read", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + }, + }, + }, + }, + } classificationPDP, err := NewPolicyDecisionPoint( s.T().Context(), s.logger, []*policy.Attribute{f.classificationAttr}, []*policy.SubjectMapping{allActionsPublicMapping, restrictedMapping}, - []*policy.RegisteredResource{}, + []*policy.RegisteredResource{f.classificationRegRes, allActionsPublicRegRes, restrictedRegRes}, ) s.Require().NoError(err) s.Require().NotNil(classificationPDP) @@ -869,7 +1368,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { }) // Resource with restricted classification - restrictedResources := createResourcePerFqn(testClassConfidentialFQN) + restrictedResources := createResourcePerFqn(testClassConfidentialFQN, testClassConfidentialRegResFQN) // Test read access - should be allowed for restricted decision, err := classificationPDP.GetDecision(s.T().Context(), entity, actionRead, restrictedResources) @@ -918,7 +1417,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Single resource with both HIERARCHY (classification) and ANY_OF (department) attributes - combinedResource := createResource("secret-engineering-resource", testClassSecretFQN, testDeptEngineeringFQN) + combinedResource := createAttributeValueResource("secret-engineering-resource", testClassSecretFQN, testDeptEngineeringFQN) // Test read access (both allow) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) @@ -947,7 +1446,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Single resource with both HIERARCHY and ALL_OF attributes - combinedResource := createResource("secret-usa-resource", testClassSecretFQN, testCountryUSAFQN) + combinedResource := createAttributeValueResource("secret-usa-resource", testClassSecretFQN, testCountryUSAFQN) // Test read access (both allow) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) @@ -970,7 +1469,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Single resource with both ANY_OF and ALL_OF attributes - combinedResource := createResource("engineering-usa-uk-resource", testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN) + combinedResource := createAttributeValueResource("engineering-usa-uk-resource", testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN) // Test read access (both allow) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) @@ -994,7 +1493,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // 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) + combinedResource := createAttributeValueResource("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}) @@ -1029,7 +1528,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Single resource with all three attribute rule types - combinedResource := createResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) + combinedResource := createAttributeValueResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) // Test read access (all three allow) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) @@ -1055,7 +1554,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Resource with all three attribute types - combinedResource := createResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) + combinedResource := createAttributeValueResource("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}) @@ -1094,7 +1593,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Resource with attributes from different namespaces and with different rules - complexResource := createResource("complex-multi-ns-resource", + complexResource := createAttributeValueResource("complex-multi-ns-resource", testClassSecretFQN, // HIERARCHY rule testCountryUSAFQN, // ALL_OF rule testProjectAlphaFQN, // ANY_OF rule @@ -1180,7 +1679,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() s.Run("Multiple HIERARCHY of different levels", func() { // Create a resource with multiple classifications (hierarchy rule) - cascadingResource := createResource("classification-cascade-resource", + cascadingResource := createAttributeValueResource("classification-cascade-resource", testClassSecretFQN, // Secret classification testClassConfidentialFQN, // Confidential classification (lower than Secret) ) @@ -1303,7 +1802,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { }) // Resource with attribute values from two different namespaces - resource := createResource("secret-alpha-cloud-fqn", testClassSecretFQN, testProjectAlphaFQN, testPlatformCloudFQN) + resource := createAttributeValueResource("secret-alpha-cloud-fqn", testClassSecretFQN, testProjectAlphaFQN, testPlatformCloudFQN) decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) @@ -1489,7 +1988,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { }) // A single resource with attribute values from different namespaces - combinedResource := createResource("combined-multi-ns-resource", + combinedResource := createAttributeValueResource("combined-multi-ns-resource", testClassConfidentialFQN, // base namespace testCountryUSAFQN, // base namespace testProjectBetaFQN, // secondary namespace @@ -1546,6 +2045,220 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { }) } +func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { + f := s.fixtures + + regResS3BucketEntity := &policy.RegisteredResource{ + Name: "s3-bucket", + Values: []*policy.RegisteredResourceValue{ + { + Value: "ts-engineering", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassTopSecretFQN, + Value: "topsecret", + }, + }, + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptEngineeringFQN, + Value: "engineering", + }, + }, + }, + }, + { + Value: "confidential-finance", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassConfidentialFQN, + Value: "confidential", + }, + }, + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptFinanceFQN, + Value: "finance", + }, + }, + }, + }, + { + Value: "secret-engineering", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testClassSecretFQN, + Value: "secret", + }, + }, + { + Action: testActionRead, + AttributeValue: &policy.Value{ + Fqn: testDeptEngineeringFQN, + Value: "engineering", + }, + }, + }, + }, + }, + } + + // 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.topSecretMapping, f.confidentialMapping, f.publicMapping, f.engineeringMapping, f.financeMapping}, + []*policy.RegisteredResource{f.networkRegRes, regResS3BucketEntity}, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + s.Run("Multiple resources and entitled actions/attributes - full access", func() { + entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") + resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN, testNetworkPrivateFQN, testNetworkPublicFQN) + + decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 4) + + expectedResults := map[string]bool{ + testClassSecretFQN: true, + testDeptEngineeringFQN: true, + testNetworkPrivateFQN: true, + testNetworkPublicFQN: 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 entitled actions/attributes of varied casing - full access", func() { + entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") + secretFQN := strings.ToUpper(testClassSecretFQN) + networkPrivateFQN := strings.ToUpper(testNetworkPrivateFQN) + + resources := createResourcePerFqn(secretFQN, testDeptEngineeringFQN, networkPrivateFQN, testNetworkPublicFQN) + + decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Access) + s.Len(decision.Results, 4) + + expectedResults := map[string]bool{ + secretFQN: true, + testDeptEngineeringFQN: true, + networkPrivateFQN: true, + testNetworkPublicFQN: 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() { + entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "confidential-finance") + + resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN, testNetworkPrivateFQN, testNetworkPublicFQN) + + decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionUpdate, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 4) + + expectedResults := map[string]bool{ + testClassSecretFQN: false, + testDeptEngineeringFQN: false, + testNetworkPrivateFQN: false, + testNetworkPublicFQN: false, + } + + s.assertAllDecisionResults(decision, expectedResults) + 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("Multiple resources and unentitled actions - full denial", func() { + entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") + + resources := createResourcePerFqn(testDeptEngineeringFQN, testClassSecretFQN, testNetworkPrivateFQN, testNetworkPublicFQN) + + // Get decision for delete action (not allowed by either attribute's subject mappings) + decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionDelete, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) + s.Len(decision.Results, 4) + + expectedResults := map[string]bool{ + testDeptEngineeringFQN: false, + testClassSecretFQN: false, + testNetworkPrivateFQN: false, + testNetworkPublicFQN: false, + } + s.assertAllDecisionResults(decision, expectedResults) + }) + + s.Run("Multiple resources - partial access", func() { + entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "secret-engineering") + + resources := createResourcePerFqn(testClassSecretFQN, testDeptFinanceFQN, testNetworkPrivateFQN, testNetworkPublicFQN) + + decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Access) // False because one resource is denied + s.Len(decision.Results, 4) + + expectedResults := map[string]bool{ + testClassSecretFQN: true, + testDeptFinanceFQN: false, + testNetworkPrivateFQN: true, + testNetworkPublicFQN: true, + } + s.assertAllDecisionResults(decision, expectedResults) + + // Validate proper data rule results + for _, result := range decision.Results { + s.Len(result.DataRuleResults, 1) + + 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) + } + } + }) +} + // TestGetEntitlements tests the functionality of retrieving entitlements for entities func (s *PDPTestSuite) Test_GetEntitlements() { f := s.fixtures @@ -1963,7 +2676,7 @@ func (s *PDPTestSuite) Test_GetEntitlementsRegisteredResource() { ) s.Require().Error(err) - s.Require().ErrorIs(err, ErrInvalidRegisteredResourceValue) + s.Require().ErrorIs(err, ErrInvalidResource) s.Require().Nil(entitlements) }) @@ -2134,8 +2847,8 @@ func (s *PDPTestSuite) createEntityWithProps(entityID string, props map[string]i } } -// createResource creates a resource with attribute values -func createResource(ephemeralID string, attributeValueFQNs ...string) *authz.Resource { +// createAttributeValueResource creates a resource with attribute values +func createAttributeValueResource(ephemeralID string, attributeValueFQNs ...string) *authz.Resource { return &authz.Resource{ EphemeralId: ephemeralID, Resource: &authz.Resource_AttributeValues_{ @@ -2146,12 +2859,32 @@ func createResource(ephemeralID string, attributeValueFQNs ...string) *authz.Res } } +// createRegisteredResource creates a resource with registered resource value FQN +func createRegisteredResource(ephemeralID string, registeredResourceValueFQN string) *authz.Resource { + return &authz.Resource{ + EphemeralId: ephemeralID, + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: registeredResourceValueFQN, + }, + } +} + // 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" - resources[i] = createResource(fqn, fqn) + resourceID := fqn + + // TODO: DSPX-1295 - identifier lib does not do case-insensitive parsing, so we need to ensure FQNs are lowercased + // should maybe be fixed in the identifier library? + if _, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](strings.ToLower(fqn)); err == nil { + // FQN is a registered resource value + resources[i] = createRegisteredResource(resourceID, fqn) + } else { + // FQN is an attribute value + resources[i] = createAttributeValueResource(resourceID, fqn) + } } return resources } diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index 444acf1338..bd4148daa6 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/opentdf/platform/lib/identifier" 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" @@ -41,6 +42,30 @@ func validateGetDecision(entityRepresentation *entityresolutionV2.EntityRepresen return nil } +// validateGetDecisionRegisteredResource validates the input parameters for GetDecisionRegisteredResource: +// - registeredResourceValueFQN: must be a valid registered resource value FQN +// - action: must not be nil +// - resources: must not be nil and must contain at least one resource +// +// TODO: DSPX-1295 - add unit tests to detect regressions +func validateGetDecisionRegisteredResource(registeredResourceValueFQN string, action *policy.Action, resources []*authzV2.Resource) error { + if _, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN); err != nil { + return err + } + if action.GetName() == "" { + return fmt.Errorf("action required with name: %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: diff --git a/service/pkg/cache/cache.go b/service/pkg/cache/cache.go index ee4f1f5165..27b8e50537 100644 --- a/service/pkg/cache/cache.go +++ b/service/pkg/cache/cache.go @@ -75,7 +75,7 @@ func (c *Manager) NewCache(serviceName string, log *logger.Logger, options Optio } cache.logger = log. With("subsystem", "cache"). - With("serviceTag", cache.getServiceTag()) + With("service_tag", cache.getServiceTag()) if options.Expiration > 0 { cache.logger = cache.logger. diff --git a/service/policy/db/query.sql b/service/policy/db/query.sql index 9a5c12b61e..87b72af4ba 100644 --- a/service/policy/db/query.sql +++ b/service/policy/db/query.sql @@ -1415,16 +1415,41 @@ SELECT r.id, r.name, JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, + -- Aggregate all values for this resource into a JSON array, filtering NULL entries JSON_AGG( JSON_BUILD_OBJECT( 'id', v.id, - 'value', v.value + 'value', v.value, + 'action_attribute_values', action_attrs.values ) ) FILTER (WHERE v.id IS NOT NULL) as values, counted.total FROM registered_resources r CROSS JOIN counted LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +-- Build a JSON array of action/attribute pairs for each resource value +LEFT JOIN LATERAL ( + SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'action', JSON_BUILD_OBJECT( + 'id', a.id, + 'name', a.name + ), + 'attribute_value', JSON_BUILD_OBJECT( + 'id', av.id, + 'value', av.value, + 'fqn', fqns.fqn + ) + ) + ) AS values + -- Join to get all action-attribute relationships for this resource value + FROM registered_resource_action_attribute_values rav + LEFT JOIN actions a on rav.action_id = a.id + LEFT JOIN attribute_values av on rav.attribute_value_id = av.id + LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id + -- Correlate to the outer query's resource value + WHERE rav.registered_resource_value_id = v.id +) action_attrs ON true -- required syntax for LATERAL joins GROUP BY r.id, counted.total LIMIT @limit_ OFFSET @offset_; diff --git a/service/policy/db/query.sql.go b/service/policy/db/query.sql.go index 7ff01345e0..fa3a4c06b5 100644 --- a/service/policy/db/query.sql.go +++ b/service/policy/db/query.sql.go @@ -4530,16 +4530,40 @@ SELECT r.id, r.name, JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, + -- Aggregate all values for this resource into a JSON array, filtering NULL entries JSON_AGG( JSON_BUILD_OBJECT( 'id', v.id, - 'value', v.value + 'value', v.value, + 'action_attribute_values', action_attrs.values ) ) FILTER (WHERE v.id IS NOT NULL) as values, counted.total FROM registered_resources r CROSS JOIN counted LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +LEFT JOIN LATERAL ( + SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'action', JSON_BUILD_OBJECT( + 'id', a.id, + 'name', a.name + ), + 'attribute_value', JSON_BUILD_OBJECT( + 'id', av.id, + 'value', av.value, + 'fqn', fqns.fqn + ) + ) + ) AS values + -- Join to get all action-attribute relationships for this resource value + FROM registered_resource_action_attribute_values rav + LEFT JOIN actions a on rav.action_id = a.id + LEFT JOIN attribute_values av on rav.attribute_value_id = av.id + LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id + -- Correlate to the outer query's resource value + WHERE rav.registered_resource_value_id = v.id +) action_attrs ON true -- required syntax for LATERAL joins GROUP BY r.id, counted.total LIMIT $2 OFFSET $1 @@ -4558,7 +4582,7 @@ type listRegisteredResourcesRow struct { Total int64 `json:"total"` } -// listRegisteredResources +// Build a JSON array of action/attribute pairs for each resource value // // WITH counted AS ( // SELECT COUNT(id) AS total @@ -4568,16 +4592,40 @@ type listRegisteredResourcesRow struct { // r.id, // r.name, // JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, +// -- Aggregate all values for this resource into a JSON array, filtering NULL entries // JSON_AGG( // JSON_BUILD_OBJECT( // 'id', v.id, -// 'value', v.value +// 'value', v.value, +// 'action_attribute_values', action_attrs.values // ) // ) FILTER (WHERE v.id IS NOT NULL) as values, // counted.total // FROM registered_resources r // CROSS JOIN counted // LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +// LEFT JOIN LATERAL ( +// SELECT JSON_AGG( +// JSON_BUILD_OBJECT( +// 'action', JSON_BUILD_OBJECT( +// 'id', a.id, +// 'name', a.name +// ), +// 'attribute_value', JSON_BUILD_OBJECT( +// 'id', av.id, +// 'value', av.value, +// 'fqn', fqns.fqn +// ) +// ) +// ) AS values +// -- Join to get all action-attribute relationships for this resource value +// FROM registered_resource_action_attribute_values rav +// LEFT JOIN actions a on rav.action_id = a.id +// LEFT JOIN attribute_values av on rav.attribute_value_id = av.id +// LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id +// -- Correlate to the outer query's resource value +// WHERE rav.registered_resource_value_id = v.id +// ) action_attrs ON true -- required syntax for LATERAL joins // GROUP BY r.id, counted.total // LIMIT $2 // OFFSET $1