diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 4af02e632c..35fe96ba0a 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -15,6 +15,7 @@ import ( "github.com/opentdf/platform/service/internal/access/v2/obligations" "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" "github.com/opentdf/platform/service/logger" + "google.golang.org/protobuf/types/known/wrapperspb" ) var ( @@ -25,6 +26,19 @@ var ( ErrInvalidDynamicValueMapping = errors.New("access: invalid dynamic value mapping") ) +// isExplicitlyInactive reports whether an active state was loaded and is false. An unset state is +// not inactive: targeted lookups, synthetic values, and in-memory fixtures all leave it unset. +func isExplicitlyInactive(active *wrapperspb.BoolValue) bool { + return active != nil && !active.GetValue() +} + +// isDeactivated reports whether an attribute value, or the definition owning it, is deactivated. +// Deactivated values must neither entitle an entity nor be entitleable on a resource. +func isDeactivated(attributeAndValue *attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) bool { + return isExplicitlyInactive(attributeAndValue.GetValue().GetActive()) || + isExplicitlyInactive(attributeAndValue.GetAttribute().GetActive()) +} + // getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions map func getDefinition(valueFQN string, allDefinitionsByDefFQN map[string]*policy.Attribute) (*policy.Attribute, error) { parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](valueFQN) @@ -112,7 +126,7 @@ func populateLowerValuesIfHierarchy( entitledActionsSet[action.GetName()] = action } for _, value := range definition.GetValues() { - if lower { + if lower && !isExplicitlyInactive(value.GetActive()) { alreadyEntitledActions, exists := entitledActionsPerAttributeValueFqn[value.GetFqn()] if !exists { entitledActionsPerAttributeValueFqn[value.GetFqn()] = entitledActions @@ -165,6 +179,9 @@ func populateHigherValuesIfHierarchy( ) continue } + if isDeactivated(fullValue) { + continue + } decisionableAttributes[value.GetFqn()] = &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ Value: fullValue.GetValue(), Attribute: definition, @@ -254,6 +271,15 @@ func getResourceDecisionableAttributes( attributeAndValue, ok := entitleableAttributesByValueFQN[attrValueFQN] + // A deactivated value is left out of the decisionable set so the resource carrying it is + // denied downstream, and so it is never synthesized as an ad-hoc value below. + if ok && isDeactivated(attributeAndValue) { + logger.WarnContext(ctx, "deactivated attribute value on resource - denying access", + slog.String("attribute_value_fqn", attrValueFQN), + ) + continue + } + if !ok { // The value FQN is not a concrete policy value. A synthetic value is created // when either direct entitlements are enabled (experimental) OR the parent @@ -266,6 +292,15 @@ func getResourceDecisionableAttributes( continue } + // A deactivated definition cannot back a synthetic value, or an ad-hoc value under a + // deactivated definition would remain satisfiable. + if isExplicitlyInactive(parentDefinition.GetActive()) { + logger.WarnContext(ctx, "deactivated attribute definition on resource - denying access", + slog.String("attribute_value_fqn", attrValueFQN), + ) + continue + } + _, hasDynamicMapping := dynamicMappingsByDefinitionFQN[parentDefinition.GetFqn()] if !allowDirectEntitlements && !hasDynamicMapping { // neither path enabled for this value: add to not found list and skip diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 5a1eeda521..0ba0379a81 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -343,6 +343,12 @@ func (p *PolicyDecisionPoint) GetDecision( for _, directEntitlement := range entityRepresentation.GetDirectEntitlements() { fqn := directEntitlement.GetAttributeValueFqn() + if p.isDeactivatedValueFQN(fqn) { + l.DebugContext(ctx, "skipping direct entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", fqn), + ) + continue + } actionNames := directEntitlement.GetActions() // In strict namespaced-policy mode, direct-entitlement actions must carry // the same namespace context as the entitled attribute value so they can @@ -386,6 +392,12 @@ func (p *PolicyDecisionPoint) GetDecision( return nil, nil, fmt.Errorf("%w: %w", ErrDynamicValueMappingEvaluation, err) } for fqn, actions := range dynamicEntitledFQNsToActions { + if p.isDeactivatedValueFQN(fqn) { + l.DebugContext(ctx, "skipping dynamic value mapping entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", fqn), + ) + continue + } entitledFQNsToActions[fqn] = append(entitledFQNsToActions[fqn], actions...) } l.DebugContext(ctx, "evaluated dynamic value mappings", slog.Any("dynamic_entitled_value_fqns_to_actions", dynamicEntitledFQNsToActions)) @@ -460,6 +472,13 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( attrVal := aav.GetAttributeValue() attrValFQN := attrVal.GetFqn() + if p.isDeactivatedValueFQN(attrValFQN) { + l.DebugContext(ctx, "skipping registered resource entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", attrValFQN), + ) + continue + } + requiredNamespaceFQN := "" if attrAndValue, ok2 := decisionableAttributes[attrValFQN]; ok2 { requiredNamespaceFQN = attrAndValue.GetAttribute().GetNamespace().GetFqn() @@ -559,6 +578,12 @@ func (p *PolicyDecisionPoint) GetEntitlements( actionsPerAttributeValueFqn := make(map[string]*authz.EntityEntitlements_ActionsList) for valueFQN, actions := range fqnsToActions { + if p.isDeactivatedValueFQN(valueFQN) { + l.DebugContext(ctx, "skipping entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", valueFQN), + ) + continue + } // If already entitled (such as via a higher entitled comprehensive hierarchy attr value), merge with existing if alreadyEntitled, ok := actionsPerAttributeValueFqn[valueFQN]; ok { actions = mergeDeduplicatedActions(make(map[string]*policy.Action), actions, alreadyEntitled.GetActions()) @@ -615,6 +640,13 @@ func (p *PolicyDecisionPoint) GetEntitlementsRegisteredResource( attrVal := aav.GetAttributeValue() attrValFQN := attrVal.GetFqn() + if p.isDeactivatedValueFQN(attrValFQN) { + l.DebugContext(ctx, "skipping entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", attrValFQN), + ) + continue + } + actionsList, actionsAreOK := actionsPerAttributeValueFqn[attrValFQN] if !actionsAreOK { actionsList = &authz.EntityEntitlements_ActionsList{ @@ -652,3 +684,17 @@ func (p *PolicyDecisionPoint) GetEntitlementsRegisteredResource( return result, nil } + +// isDeactivatedValueFQN reports whether the value FQN, or the definition owning it, is deactivated. +// An FQN unknown to policy under an active definition is not deactivated: it is either denied or +// synthesized by the ad-hoc value paths. +func (p *PolicyDecisionPoint) isDeactivatedValueFQN(valueFQN string) bool { + if attributeAndValue, ok := p.allEntitleableAttributesByValueFQN[valueFQN]; ok { + return isDeactivated(attributeAndValue) + } + definition, err := getDefinition(valueFQN, p.allAttributesByDefinitionFQN) + if err != nil { + return false + } + return isExplicitlyInactive(definition.GetActive()) +} diff --git a/service/internal/access/v2/pdp_deactivated_test.go b/service/internal/access/v2/pdp_deactivated_test.go new file mode 100644 index 0000000000..00758aab90 --- /dev/null +++ b/service/internal/access/v2/pdp_deactivated_test.go @@ -0,0 +1,455 @@ +package access + +import ( + 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" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// Deactivating an attribute value must fail closed on every decision path: data already tagged +// with the value can no longer be decrypted, and no entitlement source may resurrect it. +// Regression coverage for a TDF remaining decryptable after its value was deactivated. + +const deactivatedTestNamespace = "deactivation.example.com" + +var ( + testDeactivatedProjectFQN = createAttrFQN(deactivatedTestNamespace, "project") + testDeactivatedProjectActive = createAttrValueFQN(deactivatedTestNamespace, "project", "active") + testDeactivatedProjectInactive = createAttrValueFQN(deactivatedTestNamespace, "project", "inactive") + + testDeactivatedClearanceFQN = createAttrFQN(deactivatedTestNamespace, "clearance") + testDeactivatedClearanceHighInactive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "high") + testDeactivatedClearanceLowActive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "low") + testDeactivatedClearanceLowestInactive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "lowest") + + testDeactivatedArchivedFQN = createAttrFQN(deactivatedTestNamespace, "archived") + testDeactivatedArchivedValue = createAttrValueFQN(deactivatedTestNamespace, "archived", "value1") + testDeactivatedArchivedAdHocVal = createAttrValueFQN(deactivatedTestNamespace, "archived", "adhoc") +) + +// deactivationProjectAttr is an ANY_OF definition with one active and one deactivated value. +func deactivationProjectAttr() *policy.Attribute { + return &policy.Attribute{ + Fqn: testDeactivatedProjectFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Namespace: &policy.Namespace{Name: deactivatedTestNamespace, Fqn: "https://" + deactivatedTestNamespace}, + Values: []*policy.Value{ + {Fqn: testDeactivatedProjectActive, Value: "active", Active: wrapperspb.Bool(true)}, + {Fqn: testDeactivatedProjectInactive, Value: "inactive", Active: wrapperspb.Bool(false)}, + }, + } +} + +// deactivationClearanceAttr is a HIERARCHY definition, highest first, with a deactivated value +// both above and below the active one. +func deactivationClearanceAttr() *policy.Attribute { + return &policy.Attribute{ + Fqn: testDeactivatedClearanceFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Namespace: &policy.Namespace{Name: deactivatedTestNamespace, Fqn: "https://" + deactivatedTestNamespace}, + Values: []*policy.Value{ + {Fqn: testDeactivatedClearanceHighInactive, Value: "high", Active: wrapperspb.Bool(false)}, + {Fqn: testDeactivatedClearanceLowActive, Value: "low", Active: wrapperspb.Bool(true)}, + {Fqn: testDeactivatedClearanceLowestInactive, Value: "lowest", Active: wrapperspb.Bool(false)}, + }, + } +} + +// deactivationArchivedAttr is a deactivated ANY_OF definition whose values are all still active. +// Deactivating a definition must deny its values even though each value is individually active. +func deactivationArchivedAttr() *policy.Attribute { + return &policy.Attribute{ + Fqn: testDeactivatedArchivedFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Active: wrapperspb.Bool(false), + Namespace: &policy.Namespace{Name: deactivatedTestNamespace, Fqn: "https://" + deactivatedTestNamespace}, + Values: []*policy.Value{ + {Fqn: testDeactivatedArchivedValue, Value: "value1", Active: wrapperspb.Bool(true)}, + }, + } +} + +// Test_GetDecision_DeactivatedValue_SubjectMappings covers the standard access PDP path: an entity +// entitled through a subject mapping on a value that is later deactivated. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_SubjectMappings() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectInactive, "inactive", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"inactive"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-both-projects", map[string]interface{}{ + "project": []interface{}{"active", "inactive"}, + }) + + s.Run("resource tagged with the deactivated value is denied", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.AllPermitted, "subject mapping must not entitle a deactivated value") + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("ANY_OF resource carrying the deactivated value alongside an active one is denied", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource("mixed-state-resource", testDeactivatedProjectActive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.AllPermitted, "a resource carrying a deactivated value must fail closed even under ANY_OF") + }) + + s.Run("active sibling value is unaffected", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.AllPermitted) + s.Contains(entitlements, testDeactivatedProjectActive) + }) +} + +// Test_GetDecision_DeactivatedValue_Hierarchy asserts a deactivated higher hierarchy value neither +// entitles itself nor cascades entitlement down to the active values beneath it. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_Hierarchy() { + ctx := s.T().Context() + + attr := deactivationClearanceAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedClearanceHighInactive, "high", + []*policy.Action{testActionRead}, ".properties.clearance", []string{"high"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-high-clearance", map[string]interface{}{ + "clearance": "high", + }) + + s.Run("deactivated highest value denies the resource tagged with it", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedClearanceHighInactive, testDeactivatedClearanceHighInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + }) + + s.Run("deactivated higher value does not entitle a lower active value", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedClearanceLowActive, testDeactivatedClearanceLowActive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "hierarchy must not cascade entitlement from a deactivated value") + }) +} + +// Test_GetDecision_DeactivatedValue_DynamicValueMappings covers the experimental dynamic, +// definition-level value mapping path. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_DynamicValueMappings() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + mapping := &policy.DynamicValueMapping{ + AttributeDefinition: attr, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: ".properties.project[]", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + }, + Actions: []*policy.Action{testActionRead}, + Namespace: attr.GetNamespace(), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, []*policy.SubjectMapping{}, nil, + false, false, WithDynamicValueMappings([]*policy.DynamicValueMapping{mapping}, true)) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-dynamic", map[string]interface{}{ + "project": []interface{}{"active", "inactive"}, + }) + + s.Run("dynamic mapping does not entitle the deactivated value", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("dynamic mapping still entitles the active value", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) +} + +// Deactivated values on the direct-entitlement path are covered by the deactivation subtests of +// Test_GetDecision_DirectEntitlements in pdp_test.go; only the deactivated-definition case below +// is unique to this file. + +// Test_GetDecision_DeactivatedValue_RegisteredResources covers registered resources on both sides +// of a decision: as the entity being entitled, and as the resource being accessed. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_RegisteredResources() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + regResName := "deactivation_service" + entityRegResValueFQN := createRegisteredResourceValueFQN("", regResName, "entity") + inactiveRegResValueFQN := createRegisteredResourceValueFQN("", regResName, "tagged_inactive") + activeRegResValueFQN := createRegisteredResourceValueFQN("", regResName, "tagged_active") + + actionAttributeValue := func(fqn, value string) *policy.RegisteredResourceValue_ActionAttributeValue { + return &policy.RegisteredResourceValue_ActionAttributeValue{ + Action: testActionRead, + AttributeValue: &policy.Value{Fqn: fqn, Value: value}, + } + } + + regRes := &policy.RegisteredResource{ + Name: regResName, + Values: []*policy.RegisteredResourceValue{ + { + Value: "entity", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + actionAttributeValue(testDeactivatedProjectActive, "active"), + actionAttributeValue(testDeactivatedProjectInactive, "inactive"), + }, + }, + { + Value: "tagged_inactive", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{actionAttributeValue(testDeactivatedProjectInactive, "inactive")}, + }, + { + Value: "tagged_active", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{actionAttributeValue(testDeactivatedProjectActive, "active")}, + }, + }, + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, []*policy.SubjectMapping{}, + []*policy.RegisteredResource{regRes}, false, false) + s.Require().NoError(err) + + s.Run("registered resource entity is not entitled to the deactivated value", func() { + decision, entitlements, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("registered resource entity remains entitled to the active value", func() { + decision, _, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) + + s.Run("registered resource tagged with the deactivated value is denied as a resource", func() { + decision, _, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createRegisteredResource("reg-res-inactive", inactiveRegResValueFQN), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "a registered resource tagged with a deactivated value must fail closed") + }) + + s.Run("registered resource tagged with the active value is still permitted", func() { + decision, _, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createRegisteredResource("reg-res-active", activeRegResValueFQN), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) + + s.Run("registered resource entitlements omit the deactivated value", func() { + entitlements, err := pdp.GetEntitlementsRegisteredResource(ctx, entityRegResValueFQN, false) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + s.Contains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectActive) + s.NotContains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectInactive) + }) +} + +// Test_GetDecision_DeactivatedDefinition covers deactivation of the attribute definition rather +// than an individual value. Every value under it must be denied even though the values themselves +// are still active, including ad-hoc values synthesized by the direct entitlement and dynamic value +// mapping paths. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedDefinition() { + ctx := s.T().Context() + + archived := deactivationArchivedAttr() + project := deactivationProjectAttr() + allAttrs := []*policy.Attribute{archived, project} + + s.Run("subject mapping on a value of a deactivated definition denies", func() { + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedArchivedValue, "value1", + []*policy.Action{testActionRead}, ".properties.archived[]", []string{"value1"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + } + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-archived", map[string]interface{}{ + "archived": []interface{}{"value1"}, + "project": []interface{}{"active"}, + }) + + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedArchivedValue, testDeactivatedArchivedValue), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "a value under a deactivated definition must not be satisfiable") + s.NotContains(entitlements, testDeactivatedArchivedValue) + + // A value under an active definition is unaffected. + decision, _, err = pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) + + s.Run("direct entitlement on a deactivated definition denies known and ad-hoc values", func() { + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, []*policy.SubjectMapping{}, nil, true, false) + s.Require().NoError(err) + + entity := &entityresolutionV2.EntityRepresentation{ + OriginalId: "entity-direct-archived", + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + {AttributeValueFqn: testDeactivatedArchivedValue, Actions: []string{testActionRead.GetName()}}, + {AttributeValueFqn: testDeactivatedArchivedAdHocVal, Actions: []string{testActionRead.GetName()}}, + }, + } + + for _, resourceFQN := range []string{testDeactivatedArchivedValue, testDeactivatedArchivedAdHocVal} { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(resourceFQN, resourceFQN), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "direct entitlement must not permit %s under a deactivated definition", resourceFQN) + s.NotContains(entitlements, resourceFQN) + } + }) + + s.Run("dynamic value mapping on a deactivated definition denies", func() { + mapping := &policy.DynamicValueMapping{ + AttributeDefinition: archived, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: ".properties.archived[]", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + }, + Actions: []*policy.Action{testActionRead}, + Namespace: archived.GetNamespace(), + } + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, []*policy.SubjectMapping{}, nil, + false, false, WithDynamicValueMappings([]*policy.DynamicValueMapping{mapping}, true)) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-dynamic-archived", map[string]interface{}{ + "archived": []interface{}{"value1", "adhoc"}, + }) + + for _, resourceFQN := range []string{testDeactivatedArchivedValue, testDeactivatedArchivedAdHocVal} { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(resourceFQN, resourceFQN), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "dynamic value mapping must not permit %s under a deactivated definition", resourceFQN) + s.NotContains(entitlements, resourceFQN) + } + }) + + s.Run("entitlements omit values of a deactivated definition", func() { + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedArchivedValue, "value1", + []*policy.Action{testActionRead}, ".properties.archived[]", []string{"value1"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + } + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-archived-entitlements", map[string]interface{}{ + "archived": []interface{}{"value1"}, + "project": []interface{}{"active"}, + }) + + entitlements, err := pdp.GetEntitlements(ctx, []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + s.Contains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectActive) + s.NotContains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedArchivedValue) + }) +} + +// Test_GetEntitlements_ComprehensiveHierarchy_DeactivatedLowerValue asserts the comprehensive +// hierarchy cascade skips deactivated lower values. +func (s *PDPTestSuite) Test_GetEntitlements_ComprehensiveHierarchy_DeactivatedLowerValue() { + ctx := s.T().Context() + + attr := deactivationClearanceAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedClearanceLowActive, "low", + []*policy.Action{testActionRead}, ".properties.clearance", []string{"low"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-low-clearance", map[string]interface{}{ + "clearance": "low", + }) + + entitlements, err := pdp.GetEntitlements(ctx, []*entityresolutionV2.EntityRepresentation{entity}, nil, true) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + + perValueFQN := entitlements[0].GetActionsPerAttributeValueFqn() + s.Contains(perValueFQN, testDeactivatedClearanceLowActive) + s.NotContains(perValueFQN, testDeactivatedClearanceLowestInactive, + "comprehensive hierarchy must not cascade entitlement into a deactivated lower value") +} + +// Test_GetEntitlements_DeactivatedValue asserts deactivated values never surface as entitlements. +func (s *PDPTestSuite) Test_GetEntitlements_DeactivatedValue() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectInactive, "inactive", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"inactive"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-both-projects", map[string]interface{}{ + "project": []interface{}{"active", "inactive"}, + }) + + entitlements, err := pdp.GetEntitlements(ctx, []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + s.Contains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectActive) + s.NotContains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectInactive) +} diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index f3d98c3003..6eeaaa65b5 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -14,6 +14,7 @@ import ( "github.com/opentdf/platform/service/logger" "github.com/opentdf/platform/service/policy/actions" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/wrapperspb" ) // Constants for test namespaces @@ -4074,13 +4075,32 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { } attr2ValueFQN := attr2.GetValues()[0].GetFqn() + attr3 := &policy.Attribute{ + Fqn: "https://demo.com/attr/adhoc_3", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{ + { + Fqn: "https://demo.com/attr/adhoc_3/value/active_value", + Value: "active_value", + Active: wrapperspb.Bool(true), + }, + { + Fqn: "https://demo.com/attr/adhoc_3/value/inactive_value", + Value: "inactive_value", + Active: wrapperspb.Bool(false), + }, + }, + } + attr3ActiveValueFQN := attr3.GetValues()[0].GetFqn() + attr3InactiveValueFQN := attr3.GetValues()[1].GetFqn() + resAttr1ValueFqn := createAttributeValueResource(attr1ValueFQN, attr1ValueFQN) resAttr2ValueFqn := createAttributeValueResource(attr2ValueFQN, attr2ValueFQN) pdp, err := NewPolicyDecisionPoint( ctx, s.logger, - []*policy.Attribute{attr1, attr2}, + []*policy.Attribute{attr1, attr2, attr3}, []*policy.SubjectMapping{}, []*policy.RegisteredResource{}, true, // Allow direct entitlements @@ -4178,6 +4198,77 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { attr2ValueFQN: false, }) }) + + s.Run("not entitled to a deactivated value", func() { + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + { + AttributeValueFqn: attr3InactiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + }, + } + + decision, entitlements, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource(attr3InactiveValueFQN, attr3InactiveValueFQN), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.False(decision.AllPermitted, "deactivated attribute value must not be entitled by a direct entitlement") + s.NotContains(entitlements, attr3InactiveValueFQN) + s.assertAllDecisionResults(decision, map[string]bool{ + attr3InactiveValueFQN: false, + }) + }) + + s.Run("not entitled to a resource carrying both an active and a deactivated value", func() { + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + { + AttributeValueFqn: attr3ActiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + { + AttributeValueFqn: attr3InactiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + }, + } + + decision, _, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource("mixed-active-state-resource", attr3ActiveValueFQN, attr3InactiveValueFQN), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.False(decision.AllPermitted, "a resource carrying a deactivated value must not be entitled") + s.assertAllDecisionResults(decision, map[string]bool{ + "mixed-active-state-resource": false, + }) + }) + + s.Run("entitled to the active sibling of a deactivated value", func() { + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + { + AttributeValueFqn: attr3ActiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + }, + } + + decision, _, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource(attr3ActiveValueFQN, attr3ActiveValueFQN), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.True(decision.AllPermitted, "deactivation of a sibling value must not affect the active value") + s.assertAllDecisionResults(decision, map[string]bool{ + attr3ActiveValueFQN: true, + }) + }) } func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements_StrictNamespacedPolicy() { diff --git a/tests-bdd/cukes/steps_attributes.go b/tests-bdd/cukes/steps_attributes.go index ba8dae68c0..c62ca92b87 100644 --- a/tests-bdd/cukes/steps_attributes.go +++ b/tests-bdd/cukes/steps_attributes.go @@ -128,11 +128,58 @@ func (s *AttributesStepDefinitions) iSendARequestToCreateAnAttributeWithGenerate return ctx, nil } +// iDeactivateTheAttributeValue resolves the value by FQN and deactivates it. A deactivated value +// must no longer entitle an entity nor be satisfiable on a resource, so decisions and KAS rewraps +// touching it must fail closed. +func (s *AttributesStepDefinitions) iDeactivateTheAttributeValue(ctx context.Context, fqn string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + value, err := scenarioContext.GetAttributeValue(ctx, strings.TrimSpace(fqn)) + if err != nil { + return ctx, fmt.Errorf("resolve attribute value %s: %w", fqn, err) + } + + _, err = scenarioContext.SDK.Attributes.DeactivateAttributeValue(ctx, &attributes.DeactivateAttributeValueRequest{ + Id: value.GetId(), + }) + if err != nil { + return ctx, fmt.Errorf("deactivate attribute value %s: %w", fqn, err) + } + return ctx, nil +} + +// iDeactivateTheAttributeDefinition resolves the definition by FQN and deactivates it. Deactivating +// a definition does not cascade to its values in the database, so every decision path must deny on +// the definition's own state. +func (s *AttributesStepDefinitions) iDeactivateTheAttributeDefinition(ctx context.Context, fqn string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + trimmed := strings.TrimSpace(fqn) + resp, err := scenarioContext.SDK.Attributes.GetAttribute(ctx, &attributes.GetAttributeRequest{ + Identifier: &attributes.GetAttributeRequest_Fqn{Fqn: trimmed}, + }) + if err != nil { + return ctx, fmt.Errorf("resolve attribute definition %s: %w", fqn, err) + } + + _, err = scenarioContext.SDK.Attributes.DeactivateAttribute(ctx, &attributes.DeactivateAttributeRequest{ + Id: resp.GetAttribute().GetId(), + }) + if err != nil { + return ctx, fmt.Errorf("deactivate attribute definition %s: %w", fqn, err) + } + return ctx, nil +} + func RegisterAttributeStepDefinitions(ctx *godog.ScenarioContext, x *PlatformTestSuiteContext) { stepDefinitions := AttributesStepDefinitions{ PlatformCukesContext: x, } ctx.Step(`^a (anyOf|allOf|hierarchy) attribute definition with values: "([^"]*)"$`, stepDefinitions.aAttributeDef) + ctx.Step(`^I deactivate the attribute value "([^"]*)"$`, stepDefinitions.iDeactivateTheAttributeValue) + ctx.Step(`^I deactivate the attribute definition "([^"]*)"$`, stepDefinitions.iDeactivateTheAttributeDefinition) ctx.Step(`^I send a request to create an attribute with:$`, stepDefinitions.iSendARequestToCreateAnAttributeWith) ctx.Step(`^I send a request to create an attribute referenced as "([^"]*)" in namespace "([^"]*)" named "([^"]*)" with rule "([^"]*)" and (\d+) generated values$`, stepDefinitions.iSendARequestToCreateAnAttributeWithGeneratedValues) } diff --git a/tests-bdd/cukes/steps_localplatform.go b/tests-bdd/cukes/steps_localplatform.go index 042133d1ef..8dde770ab2 100644 --- a/tests-bdd/cukes/steps_localplatform.go +++ b/tests-bdd/cukes/steps_localplatform.go @@ -296,6 +296,18 @@ func (s *LocalPlatformStepDefinitions) aDefaultLocalPlatform(ctx context.Context }) } +// aDefaultLocalPlatformWithTemplate provisions the demo policy of `a default local platform` on a +// platform configured from a custom template, for scenarios that need both the demo attributes and +// non-default service config. +func (s *LocalPlatformStepDefinitions) aDefaultLocalPlatformWithTemplate(ctx context.Context, platformTemplate string) (context.Context, error) { + kt := template.Must(template.New("kc").Parse(keycloakBaseTemplate)) + return s.commonLocalPlatform(ctx, &platformStartOptions{ + platformProvisionPath: &platformTemplate, + kcProvisionPath: kt, + provisionDefaultPolicy: true, + }) +} + func (s *LocalPlatformStepDefinitions) iUseThePlatformAs(ctx context.Context, role string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) clientIDByRole := map[string]string{ @@ -598,6 +610,7 @@ func RegisterLocalPlatformStepDefinitions(ctx *godog.ScenarioContext, x *Platfor } ctx.Step(`^an empty local platform$`, platformStepDefinitions.aEmptyLocalPlatform) ctx.Step(`^a default local platform$`, platformStepDefinitions.aDefaultLocalPlatform) + ctx.Step(`^a default local platform with platform template "([^"]*)"$`, platformStepDefinitions.aDefaultLocalPlatformWithTemplate) ctx.Step(`^I use the platform as "([^"]*)"$`, platformStepDefinitions.iUseThePlatformAs) ctx.Step(`^a user exists with username "([^"]*)" and email "([^"]*)" and the following attributes:$`, platformStepDefinitions.aUser) ctx.Step(`^a local platform with platform template "([^"]*)" and keycloak template "([^"]*)"$`, platformStepDefinitions.aLocalPlatformWithTemplates) diff --git a/tests-bdd/features/deactivated-attribute-values.feature b/tests-bdd/features/deactivated-attribute-values.feature new file mode 100644 index 0000000000..de4a9882ac --- /dev/null +++ b/tests-bdd/features/deactivated-attribute-values.feature @@ -0,0 +1,68 @@ +@deactivated-attribute-values +Feature: Deactivated attribute values deny decrypt + A deactivated attribute value — or a value whose definition is deactivated — + must fail closed: it can neither entitle an entity nor be satisfied on a + resource, so a TDF already bound to it stops being decryptable the moment the + deactivation lands. + + These scenarios reuse the dynamic value mappings platform template purely for + its allow_dynamic_value_mappings flag: that flag makes authorization v2 decide + against the full entitlement policy instead of a targeted per-FQN fetch. + Deactivated values are present in that full policy load, which is how they + remained decryptable. The targeted fetch already errors on an inactive value, + so on the stock template these scenarios would pass even with the bug. + + The demo policy loaded by the default platform is used: + + demo.com/attr/department rule: ANY_OF + engineering, finance, hr + + demo.com/attr/classification rule: HIERARCHY (public < internal < confidential < secret) + + Each scenario deactivates a value, so the feature is deliberately untagged + for stateless reuse: every scenario gets its own platform and policy database. + + Background: + Given a user exists with username "alice" and email "alice@demo.com" and the following attributes: + | name | value | + | department | ["engineering"] | + | classification | ["confidential"] | + # Borrowed only for allow_dynamic_value_mappings, which forces the full-policy PDP. + And a default local platform with platform template "cukes/resources/platform.dynamic_value_mappings.template" + And a user token for "alice" stored as "alice_tok" + + Scenario: ANY_OF — deactivating the value on the TDF denies decrypt + When I encrypt plaintext "hello engineering" with attributes "https://demo.com/attr/department/value/engineering" stored as "tdf_dept" + And using token "alice_tok", decrypt "tdf_dept" stored as "plain_dept_before" + Then the decryption stored as "plain_dept_before" should succeed with plaintext "hello engineering" + When I deactivate the attribute value "https://demo.com/attr/department/value/engineering" + And using token "alice_tok", decrypt "tdf_dept" stored as "plain_dept_after" + Then the decryption stored as "plain_dept_after" should be denied + + Scenario: HIERARCHY — deactivating the value on the TDF denies decrypt + When I encrypt plaintext "internal memo" with attributes "https://demo.com/attr/classification/value/internal" stored as "tdf_class" + And using token "alice_tok", decrypt "tdf_class" stored as "plain_class_before" + Then the decryption stored as "plain_class_before" should succeed with plaintext "internal memo" + When I deactivate the attribute value "https://demo.com/attr/classification/value/internal" + And using token "alice_tok", decrypt "tdf_class" stored as "plain_class_after" + Then the decryption stored as "plain_class_after" should be denied + + # Deactivating a definition does not cascade to its values in the database — each value keeps + # active = true — so the deny must come from the definition's own state. + Scenario: Deactivating the attribute definition denies decrypt of its values + When I encrypt plaintext "hello engineering" with attributes "https://demo.com/attr/department/value/engineering" stored as "tdf_defn" + And using token "alice_tok", decrypt "tdf_defn" stored as "plain_defn_before" + Then the decryption stored as "plain_defn_before" should succeed with plaintext "hello engineering" + When I deactivate the attribute definition "https://demo.com/attr/department" + And using token "alice_tok", decrypt "tdf_defn" stored as "plain_defn_after" + Then the decryption stored as "plain_defn_after" should be denied + + # alice reaches `public` only by hierarchy cascade from her entitled + # `confidential`. Deactivating `confidential` must stop the cascade. + Scenario: HIERARCHY — deactivating the entitled value stops the cascade to lower values + When I encrypt plaintext "public notice" with attributes "https://demo.com/attr/classification/value/public" stored as "tdf_cascade" + And using token "alice_tok", decrypt "tdf_cascade" stored as "plain_cascade_before" + Then the decryption stored as "plain_cascade_before" should succeed with plaintext "public notice" + When I deactivate the attribute value "https://demo.com/attr/classification/value/confidential" + And using token "alice_tok", decrypt "tdf_cascade" stored as "plain_cascade_after" + Then the decryption stored as "plain_cascade_after" should be denied diff --git a/tests-bdd/features/direct-entitlements.feature b/tests-bdd/features/direct-entitlements.feature index e87702a061..f4e92479c9 100644 --- a/tests-bdd/features/direct-entitlements.feature +++ b/tests-bdd/features/direct-entitlements.feature @@ -65,6 +65,30 @@ Feature: Direct entitlements decisioning Then the response should be successful And I should get a "PERMIT" decision response + # A direct entitlement is supplied by the caller, so it must not be able to resurrect an + # attribute value that policy has deactivated. + Scenario: Direct entitlement denies a deactivated attribute value + Given there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + And I deactivate the attribute value "https://example.com/attr/department/value/eng" + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "DENY" decision response + + # Deactivating a definition does not cascade to its values, so the value is still active here. + # The full entitlement policy load drops deactivated definitions outright, so the resource FQN + # is unknown; GetDecision treats an unknown FQN as a per-resource deny rather than a request + # error, so the response is successful and the decision is DENY. + Scenario: Direct entitlement denies a value whose definition deactivation dropped it from policy + Given there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + And I deactivate the attribute definition "https://example.com/attr/department" + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "DENY" decision response + Scenario: Subject mapping and direct entitlement together satisfy an ALL_OF resource Given there is a claims subject entity referenced as "alice" with direct entitlements: | attribute_value_fqn | actions |