From 70947686ddb429be63a437720c2c2ab8efa6e62b Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Mon, 30 Mar 2026 10:27:04 -0400 Subject: [PATCH 01/39] adr --- ...namespaced-subject-mappings-decisioning.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md diff --git a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md new file mode 100644 index 0000000000..da9c0a92b5 --- /dev/null +++ b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md @@ -0,0 +1,72 @@ +--- +status: 'proposed' +date: '2026-03-30' +tags: + - policy + - authorization + - namespaced-policy +driver: '@elizabethhealy' +--- + +# Namespaced Subject Mapping Decisioning in PDP + +## Context and Problem Statement + +Policy objects are moving toward strict namespace ownership, but access decisioning still treats actions as unscoped names in several evaluation paths. This creates ambiguity when a request action name exists in multiple namespaces and when a single resource includes attributes from multiple namespaces. + +We need a decisioning model that is namespace-correct, fail-closed, and compatible with staged rollout using the `NamespacedPolicy` feature flag. + +## Decision Drivers + +- Preserve existing multi-namespace resource semantics (`AND` behavior) while adding namespace correctness. +- Prevent cross-namespace action matches when namespaced policy mode is enabled. +- Keep rollout safe via feature-flagged behavior split. +- Avoid startup coupling by keeping standard-action checks lazy at evaluation time. + +## Decision Outcome + +Chosen option: **Resolve request action by name within each evaluation namespace context**. + +The request action remains unnamespaced in the decision request. During evaluation, action matching is performed per namespace context (derived from the rule/value being evaluated), not globally. + +Feature-flag mode split: + +- `NamespacedPolicy=false`: evaluate ONLY unnamespaced subject mappings and unnamespaced actions. +- `NamespacedPolicy=true`: evaluate ONLY namespaced subject mappings and require action namespace equality for each evaluated namespace. + +For multi-namespace resources, existing `AND` semantics remain unchanged: all required namespace-scoped checks must pass, and missing action support in any required namespace denies access. + +## Consequences + +- 🟩 **Good**, because action evaluation becomes deterministic and namespace-safe. +- 🟩 **Good**, because feature-flagged split allows staged migration without mixed-mode ambiguity. +- 🟩 **Good**, because fail-closed behavior prevents accidental entitlement via cross-namespace action reuse. +- 🟥 **Bad**, because policy admins must ensure required actions exist in each relevant namespace. +- 🟥 **Bad**, because debugging becomes harder without explicit namespace-aware logs. + +## Validation + +Validation is done through PDP and decisioning tests covering: + +- mode split (`NamespacedPolicy=false` vs `true`) for subject mapping inclusion, +- namespace-aware action matching in rule evaluation paths, +- multi-namespace resource behavior where one missing namespace action causes deny, +- regression checks to confirm existing `AND` behavior is preserved. + +## Implementation Notes + +- Thread `NamespacedPolicy` into PDP runtime configuration. +- Filter subject mappings at PDP construction by mode (namespaced vs unnamespaced). +- Centralize action matching in a namespace-aware helper used by all rule/action checks. +- Derive required namespace per evaluated value/rule context. +- Keep standard/custom action existence checks lazy at evaluation time. +- Add debug logs including requested action, required namespace, candidate namespace, and rule/value context. + +## Rollout + +1. Land logic behind `NamespacedPolicy`. +2. Keep default mode as legacy (`false`) until policy data migration is complete. +3. Validate namespaced policy data readiness. +4. Flip `NamespacedPolicy=true` and monitor mismatch/deny behavior. +5. Remove legacy branch once namespaced mode is stable. + From 51273dffd83bc8e1917c2f94cf849daed894ebed Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 00:05:50 -0400 Subject: [PATCH 02/39] namespaced policy in decisions --- ...namespaced-subject-mappings-decisioning.md | 13 +- service/authorization/v2/authorization.go | 8 +- service/authorization/v2/config.go | 5 + service/internal/access/v2/evaluate.go | 109 ++++- service/internal/access/v2/evaluate_test.go | 400 +++++++++++++++++- service/internal/access/v2/helpers_test.go | 8 +- .../internal/access/v2/just_in_time_pdp.go | 3 +- service/internal/access/v2/pdp.go | 19 +- service/internal/access/v2/pdp_test.go | 376 +++++++++++++++- .../subject_mapping_builtin_actions.go | 78 +++- .../subject_mapping_builtin_actions_test.go | 31 ++ 11 files changed, 1020 insertions(+), 30 deletions(-) diff --git a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md index da9c0a92b5..c0a8b419cb 100644 --- a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md +++ b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md @@ -29,10 +29,18 @@ Chosen option: **Resolve request action by name within each evaluation namespace The request action remains unnamespaced in the decision request. During evaluation, action matching is performed per namespace context (derived from the rule/value being evaluated), not globally. +Request-action identity precedence is explicit: + +1. `action.id` (exact identity) +2. `action.name + action.namespace` (scoped identity) +3. `action.name` only (contextual identity) + +When identity is explicit (`id` or `name+namespace`), decisioning does not fall back to looser name-only matching. It fails closed only if that explicit identity is unresolved or mismatched for the evaluated namespace context. + Feature-flag mode split: -- `NamespacedPolicy=false`: evaluate ONLY unnamespaced subject mappings and unnamespaced actions. -- `NamespacedPolicy=true`: evaluate ONLY namespaced subject mappings and require action namespace equality for each evaluated namespace. +- `NamespacedPolicy=false`: preserve existing legacy behavior (no new namespace filtering semantics introduced by this change). +- `NamespacedPolicy=true`: enforce namespaced subject mapping evaluation and require action namespace equality for each evaluated namespace. For multi-namespace resources, existing `AND` semantics remain unchanged: all required namespace-scoped checks must pass, and missing action support in any required namespace denies access. @@ -69,4 +77,3 @@ Validation is done through PDP and decisioning tests covering: 3. Validate namespaced policy data readiness. 4. Flip `NamespacedPolicy=true` and monitor mismatch/deny behavior. 5. Remove legacy branch once namespaced mode is stable. - diff --git a/service/authorization/v2/authorization.go b/service/authorization/v2/authorization.go index 4fb8e18b55..dc9795cbc0 100644 --- a/service/authorization/v2/authorization.go +++ b/service/authorization/v2/authorization.go @@ -149,7 +149,7 @@ func (as *Service) GetEntitlements(ctx context.Context, req *connect.Request[aut withComprehensiveHierarchy := req.Msg.GetWithComprehensiveHierarchy() // When authorization service can consume cached policy, switch to the other PDP (process based on policy passed in) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetEntitlements, ErrFailedToInitPDP, err)) } @@ -176,7 +176,7 @@ func (as *Service) GetDecision(ctx context.Context, req *connect.Request[authzV2 propagator := otel.GetTextMapPropagator() ctx = propagator.Extract(ctx, propagation.HeaderCarrier(req.Header())) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } @@ -226,7 +226,7 @@ func (as *Service) GetDecisionMultiResource(ctx context.Context, req *connect.Re propagator := otel.GetTextMapPropagator() ctx = propagator.Extract(ctx, propagation.HeaderCarrier(req.Header())) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } @@ -279,7 +279,7 @@ func (as *Service) GetDecisionBulk(ctx context.Context, req *connect.Request[aut propagator := otel.GetTextMapPropagator() ctx = propagator.Extract(ctx, propagation.HeaderCarrier(req.Header())) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } diff --git a/service/authorization/v2/config.go b/service/authorization/v2/config.go index 24cc4d3236..81b001d04f 100644 --- a/service/authorization/v2/config.go +++ b/service/authorization/v2/config.go @@ -20,6 +20,9 @@ type Config struct { // enable entity direct entitlements that do not require subject mappings AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"` + + // enforce strict namespaced policy evaluation behavior in access decisioning + NamespacedPolicy bool `mapstructure:"namespaced_policy" json:"namespaced_policy" default:"false"` } // Validate tests for a sensible configuration @@ -56,5 +59,7 @@ func (c *Config) LogValue() slog.Value { slog.String("refresh_interval", c.Cache.RefreshInterval), ), ), + slog.Bool("allow_direct_entitlements", c.AllowDirectEntitlements), + slog.Bool("namespaced_policy", c.NamespacedPolicy), ) } diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index e787fa62ec..31f392d2af 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -34,6 +34,7 @@ func getResourceDecision( entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resource *authz.Resource, + namespacedPolicy bool, ) (*ResourceDecision, error) { var ( resourceID = resource.GetEphemeralId() @@ -80,9 +81,13 @@ func getResourceDecision( } for _, aav := range regResValue.GetActionAttributeValues() { aavAttrValueFQN := aav.GetAttributeValue().GetFqn() + requiredNamespaceID := "" + if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { + requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() + } // skip evaluating attribute rules on any action-attribute-values without the requested action - if aav.GetAction().GetName() != action.GetName() { + if !isRequestedActionMatch(action, requiredNamespaceID, aav.GetAction(), namespacedPolicy) { continue } @@ -111,7 +116,7 @@ func getResourceDecision( return failure, nil } - return evaluateResourceAttributeValues(ctx, l, resourceAttributeValues, resourceID, registeredResourceValueFQN, action, entitlements, accessibleAttributeValues) + return evaluateResourceAttributeValues(ctx, l, resourceAttributeValues, resourceID, registeredResourceValueFQN, action, entitlements, accessibleAttributeValues, namespacedPolicy) } // evaluateResourceAttributeValues evaluates a list of attribute values against the action and entitlements @@ -125,6 +130,7 @@ func evaluateResourceAttributeValues( action *policy.Action, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + namespacedPolicy bool, ) (*ResourceDecision, error) { // Group value FQNs by parent definition definitionFqnToValueFqns := make(map[string][]string) @@ -167,7 +173,7 @@ func evaluateResourceAttributeValues( return nil, fmt.Errorf("%w: %s", ErrDefinitionNotFound, defFQN) } - dataRuleResult, err := evaluateDefinition(ctx, l, entitlements, action, resourceValueFQNs, definition) + dataRuleResult, err := evaluateDefinition(ctx, l, entitlements, action, resourceValueFQNs, definition, namespacedPolicy) if err != nil { return nil, errors.Join(ErrFailedEvaluation, err) } @@ -197,8 +203,10 @@ func evaluateDefinition( action *policy.Action, resourceValueFQNs []string, attrDefinition *policy.Attribute, + namespacedPolicy bool, ) (*DataRuleResult, error) { var entitlementFailures []EntitlementFailure + requiredNamespaceID := attrDefinition.GetNamespace().GetId() l = l.With("definitionRule", attrDefinition.GetRule().String()) l = l.With("definitionFQN", attrDefinition.GetFqn()) @@ -211,13 +219,13 @@ func evaluateDefinition( switch attrDefinition.GetRule() { case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: - entitlementFailures = allOfRule(ctx, l, entitlements, action, resourceValueFQNs) + entitlementFailures = allOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: - entitlementFailures = anyOfRule(ctx, l, entitlements, action, resourceValueFQNs) + entitlementFailures = anyOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: - entitlementFailures = hierarchyRule(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition) + entitlementFailures = hierarchyRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, requiredNamespaceID, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: return nil, fmt.Errorf("%w: %s, rule: %s", ErrMissingRequiredSpecifiedRule, attrDefinition.GetFqn(), attrDefinition.GetRule().String()) @@ -252,6 +260,18 @@ func allOfRule( entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, +) []EntitlementFailure { + return allOfRuleWithContext(nil, nil, entitlements, action, resourceValueFQNs, "", false) +} + +func allOfRuleWithContext( + _ context.Context, + _ *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, + requiredNamespaceID string, + namespacedPolicy bool, ) []EntitlementFailure { actionName := action.GetName() failures := make([]EntitlementFailure, 0, len(resourceValueFQNs)) // Pre-allocate for efficiency @@ -263,7 +283,7 @@ func allOfRule( // Check if this FQN has the entitled action if entitledActions, ok := entitlements[valueFQN]; ok { for _, entitledAction := range entitledActions { - if strings.EqualFold(entitledAction.GetName(), actionName) { + if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { hasEntitlement = true break } @@ -292,6 +312,18 @@ func anyOfRule( entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, +) []EntitlementFailure { + return anyOfRuleWithContext(nil, nil, entitlements, action, resourceValueFQNs, "", false) +} + +func anyOfRuleWithContext( + _ context.Context, + _ *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, + requiredNamespaceID string, + namespacedPolicy bool, ) []EntitlementFailure { // No resources to check if len(resourceValueFQNs) == 0 { @@ -309,7 +341,7 @@ func anyOfRule( entitledActions, ok := entitlements[valueFQN] if ok { for _, entitledAction := range entitledActions { - if strings.EqualFold(entitledAction.GetName(), actionName) { + if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { foundEntitlementForThisFQN = true anyEntitlementFound = true break @@ -344,6 +376,19 @@ func hierarchyRule( action *policy.Action, resourceValueFQNs []string, attrDefinition *policy.Attribute, +) []EntitlementFailure { + return hierarchyRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, "", false) +} + +func hierarchyRuleWithContext( + ctx context.Context, + l *logger.Logger, + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, + action *policy.Action, + resourceValueFQNs []string, + attrDefinition *policy.Attribute, + requiredNamespaceID string, + namespacedPolicy bool, ) []EntitlementFailure { // No resources to check if len(resourceValueFQNs) == 0 { @@ -374,7 +419,7 @@ func hierarchyRule( if idx, exists := valueFQNToIndex[entitlementFQN]; exists && idx <= lowestValueFQNIndex { // Check if the required action is entitled for _, entitledAction := range entitledActions { - if strings.EqualFold(entitledAction.GetName(), actionName) { + if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { l.DebugContext(ctx, "hierarchy rule satisfied", slog.Group("entitled_by_value", slog.String("FQN", entitlementFQN), @@ -397,7 +442,7 @@ func hierarchyRule( foundValue := false if entitledActions, ok := entitlements[valueFQN]; ok { for _, entitledAction := range entitledActions { - if strings.EqualFold(entitledAction.GetName(), actionName) { + if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { foundValue = true break } @@ -414,3 +459,47 @@ func hierarchyRule( return entitlementFailures } + +func isRequestedActionMatch(requestedAction *policy.Action, requiredNamespaceID string, entitledAction *policy.Action, namespacedPolicy bool) bool { + if requestedAction == nil || entitledAction == nil { + return false + } + + if requestedAction.GetId() != "" { + if requestedAction.GetId() != entitledAction.GetId() { + return false + } + } else { + if requestedAction.GetName() == "" || !strings.EqualFold(requestedAction.GetName(), entitledAction.GetName()) { + return false + } + } + + if requestNamespace := requestedAction.GetNamespace(); requestNamespace != nil && (requestNamespace.GetId() != "" || requestNamespace.GetFqn() != "") { + entitledNamespace := entitledAction.GetNamespace() + if entitledNamespace == nil { + return false + } + if requestNamespace.GetId() != "" && entitledNamespace.GetId() != requestNamespace.GetId() { + return false + } + if requestNamespace.GetId() == "" && requestNamespace.GetFqn() != "" && !strings.EqualFold(entitledNamespace.GetFqn(), requestNamespace.GetFqn()) { + return false + } + } + + if !namespacedPolicy { + return true + } + + if requiredNamespaceID == "" { + return false + } + + entitledNamespace := entitledAction.GetNamespace() + if entitledNamespace == nil || entitledNamespace.GetId() == "" { + return false + } + + return entitledNamespace.GetId() == requiredNamespaceID +} diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 9e096afe1a..d90ea39709 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -1,9 +1,11 @@ package access import ( + "errors" "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" authz "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -698,7 +700,7 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() { for _, tc := range tests { s.Run(tc.name, func() { - result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValues, tc.definition) + result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValues, tc.definition, false) if tc.expectError { s.Require().Error(err) @@ -711,6 +713,193 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() { } } +func (s *EvaluateTestSuite) TestEvaluateDefinition_NamespacedPolicy() { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://ns-b.example.com"} + + allOfDef := &policy.Attribute{ + Id: "all-of-def", + Fqn: projectFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Namespace: namespaceA, + Values: []*policy.Value{ + {Fqn: projectAvengersFQN}, + {Fqn: projectJusticeLeagueFQN}, + }, + } + + anyOfDef := &policy.Attribute{ + Id: "any-of-def", + Fqn: departmentFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Namespace: namespaceA, + Values: []*policy.Value{ + {Fqn: deptFinanceFQN}, + {Fqn: deptMarketingFQN}, + }, + } + + hierarchyDef := &policy.Attribute{ + Id: "hierarchy-def", + Fqn: levelFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Namespace: namespaceA, + Values: []*policy.Value{ + {Fqn: levelHighestFQN}, + {Fqn: levelUpperMidFQN}, + {Fqn: levelMidFQN}, + }, + } + + tests := []struct { + name string + definition *policy.Attribute + resourceFQNs []string + entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + requested *policy.Action + namespaced bool + expectPass bool + expectErr error + errorContains string + }{ + { + name: "all_of strict pass with matching namespace", + definition: allOfDef, + resourceFQNs: []string{ + projectAvengersFQN, + projectJusticeLeagueFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: {{Name: actions.ActionNameRead, Namespace: namespaceA}}, + projectJusticeLeagueFQN: {{Name: actions.ActionNameRead, Namespace: namespaceA}}, + }, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectPass: true, + }, + { + name: "all_of strict fail with wrong namespace", + definition: allOfDef, + resourceFQNs: []string{ + projectAvengersFQN, + projectJusticeLeagueFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: {{Name: actions.ActionNameRead, Namespace: namespaceB}}, + projectJusticeLeagueFQN: {{Name: actions.ActionNameRead, Namespace: namespaceB}}, + }, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectPass: false, + }, + { + name: "any_of strict pass when one namespace-matching action exists", + definition: anyOfDef, + resourceFQNs: []string{ + deptFinanceFQN, + deptMarketingFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: {{Name: actions.ActionNameRead, Namespace: namespaceB}}, + deptMarketingFQN: {{Name: actions.ActionNameRead, Namespace: namespaceA}}, + }, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectPass: true, + }, + { + name: "hierarchy strict pass with higher entitled value in same namespace", + definition: hierarchyDef, + resourceFQNs: []string{ + levelUpperMidFQN, + levelMidFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + levelHighestFQN: {{Name: actions.ActionNameRead, Namespace: namespaceA}}, + }, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectPass: true, + }, + { + name: "strict mode fails when definition namespace missing", + definition: &policy.Attribute{Id: "def-no-ns", Fqn: levelFQN, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, Values: []*policy.Value{{Fqn: levelMidFQN}}}, + resourceFQNs: []string{ + levelMidFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + levelMidFQN: {{Name: actions.ActionNameRead, Namespace: namespaceA}}, + }, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectPass: false, + }, + { + name: "request action namespace filter is enforced", + definition: anyOfDef, + resourceFQNs: []string{ + deptFinanceFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: {{Name: actions.ActionNameRead, Namespace: namespaceA}}, + }, + requested: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceB}, + namespaced: false, + expectPass: false, + }, + { + name: "request action id precedence is enforced", + definition: anyOfDef, + resourceFQNs: []string{ + deptFinanceFQN, + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ + deptFinanceFQN: {{Id: "entitled-id", Name: actions.ActionNameRead, Namespace: namespaceA}}, + }, + requested: &policy.Action{Id: "requested-id", Name: actions.ActionNameRead}, + namespaced: true, + expectPass: false, + }, + { + name: "unspecified rule returns expected error", + definition: &policy.Attribute{Fqn: levelFQN, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED, Values: []*policy.Value{{Fqn: levelMidFQN}}}, + resourceFQNs: []string{levelMidFQN}, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectErr: ErrMissingRequiredSpecifiedRule, + errorContains: "cannot be unspecified", + }, + { + name: "unknown rule returns expected error", + definition: &policy.Attribute{Fqn: levelFQN, Rule: policy.AttributeRuleTypeEnum(999)}, + resourceFQNs: []string{levelMidFQN}, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + requested: &policy.Action{Name: actions.ActionNameRead}, + namespaced: true, + expectErr: ErrUnrecognizedRule, + errorContains: "unrecognized", + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, tc.requested, tc.resourceFQNs, tc.definition, tc.namespaced) + + if tc.expectErr != nil { + s.Require().Error(err) + s.True(errors.Is(err, tc.expectErr)) + s.ErrorContains(err, tc.errorContains) + return + } + + s.Require().NoError(err) + s.Require().NotNil(result) + s.Equal(tc.expectPass, result.Passed) + }) + } +} + // Test cases for evaluateResourceAttributeValues func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { tests := []struct { @@ -792,6 +981,7 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { s.action, tc.entitlements, s.accessibleAttrValues, + false, ) if tc.expectError { @@ -1022,6 +1212,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { tc.entitlements, s.action, tc.resource, + false, ) if tc.expectError { @@ -1094,6 +1285,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_MultiResources_GranularDeni entitlements, s.action, tc.resource, + false, ) s.Require().NoError(err, "Should not error for resource: %s", tc.name) @@ -1102,3 +1294,209 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_MultiResources_GranularDeni }) } } + +func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesOnActionNamespaceMismatch() { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://ns-b.example.com"} + + s.accessibleAttrValues[projectAvengersFQN].Attribute.Namespace = namespaceA + + resource := &authz.Resource{ + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{Fqns: []string{projectAvengersFQN}}, + }, + EphemeralId: "ns-mismatch-resource", + } + + entitlements := subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: { + {Name: actions.ActionNameRead, Namespace: namespaceB}, + }, + } + + decision, err := getResourceDecision( + s.T().Context(), + s.logger, + s.accessibleAttrValues, + s.accessibleRegisteredResourceValues, + entitlements, + actionRead, + resource, + true, + ) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Entitled, "desired namespaced-policy behavior: same-name action in wrong namespace should deny") +} + +func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_RegisteredResourceFiltersAAVByNamespace() { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://ns-b.example.com"} + + s.accessibleAttrValues[levelHighestFQN].Attribute.Namespace = namespaceA + s.accessibleRegisteredResourceValues[netRegResValFQN].ActionAttributeValues = []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Id: "network-action-attr-val-wrong-ns", + Action: &policy.Action{ + Name: actions.ActionNameRead, + Namespace: namespaceB, + }, + AttributeValue: &policy.Value{Fqn: levelHighestFQN, Value: "highest"}, + }, + } + + resource := &authz.Resource{ + Resource: &authz.Resource_RegisteredResourceValueFqn{RegisteredResourceValueFqn: netRegResValFQN}, + EphemeralId: "rr-ns-mismatch-resource", + } + + entitlements := subjectmappingbuiltin.AttributeValueFQNsToActions{ + levelHighestFQN: { + {Name: actions.ActionNameRead, Namespace: namespaceA}, + }, + } + + decision, err := getResourceDecision( + s.T().Context(), + s.logger, + s.accessibleAttrValues, + s.accessibleRegisteredResourceValues, + entitlements, + actionRead, + resource, + true, + ) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Entitled, "desired namespaced-policy behavior: RR AAV action namespace must match evaluated namespace") +} + +func (s *EvaluateTestSuite) Test_getResourceDecision_RequestActionIDPrecedence() { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + + s.accessibleAttrValues[projectAvengersFQN].Attribute.Namespace = namespaceA + + resource := &authz.Resource{ + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{Fqns: []string{projectAvengersFQN}}, + }, + EphemeralId: "request-action-id-precedence", + } + + entitlements := subjectmappingbuiltin.AttributeValueFQNsToActions{ + projectAvengersFQN: { + {Id: "entitled-id", Name: actions.ActionNameRead, Namespace: namespaceA}, + }, + } + + decision, err := getResourceDecision( + s.T().Context(), + s.logger, + s.accessibleAttrValues, + s.accessibleRegisteredResourceValues, + entitlements, + &policy.Action{Id: "different-id", Name: actions.ActionNameRead}, + resource, + true, + ) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Entitled, "requested action id should take precedence over name match") +} + +func Test_isRequestedActionMatch(t *testing.T) { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://ns-b.example.com"} + + tests := []struct { + name string + requestedAction *policy.Action + requiredNamespace string + entitledAction *policy.Action + namespacedPolicy bool + expectedActionMatch bool + }{ + { + name: "nil requested action", + requestedAction: nil, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, + namespacedPolicy: true, + expectedActionMatch: false, + }, + { + name: "id precedence denies same-name different-id", + requestedAction: &policy.Action{Id: "requested-id", Name: actions.ActionNameRead}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Id: "entitled-id", Name: actions.ActionNameRead, Namespace: namespaceA}, + namespacedPolicy: true, + expectedActionMatch: false, + }, + { + name: "name is case-insensitive in legacy mode", + requestedAction: &policy.Action{Name: "READ"}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: "read"}, + namespacedPolicy: false, + expectedActionMatch: true, + }, + { + name: "request namespace id must match entitled namespace id", + requestedAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceB}, + namespacedPolicy: false, + expectedActionMatch: false, + }, + { + name: "request namespace fqn must match entitled namespace fqn", + requestedAction: &policy.Action{Name: actions.ActionNameRead, Namespace: &policy.Namespace{Fqn: "https://ns-a.example.com"}}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: &policy.Namespace{Id: namespaceA.GetId(), Fqn: "HTTPS://NS-A.EXAMPLE.COM"}}, + namespacedPolicy: false, + expectedActionMatch: true, + }, + { + name: "strict mode requires required namespace id", + requestedAction: &policy.Action{Name: actions.ActionNameRead}, + requiredNamespace: "", + entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, + namespacedPolicy: true, + expectedActionMatch: false, + }, + { + name: "strict mode requires entitled action namespace id", + requestedAction: &policy.Action{Name: actions.ActionNameRead}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: actions.ActionNameRead}, + namespacedPolicy: true, + expectedActionMatch: false, + }, + { + name: "strict mode allows matching namespace id", + requestedAction: &policy.Action{Name: actions.ActionNameRead}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, + namespacedPolicy: true, + expectedActionMatch: true, + }, + { + name: "strict mode denies mismatched namespace id", + requestedAction: &policy.Action{Name: actions.ActionNameRead}, + requiredNamespace: namespaceA.GetId(), + entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceB}, + namespacedPolicy: true, + expectedActionMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched := isRequestedActionMatch(tt.requestedAction, tt.requiredNamespace, tt.entitledAction, tt.namespacedPolicy) + assert.Equal(t, tt.expectedActionMatch, matched) + }) + } +} diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index d891c6a8e1..ef27f29aed 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -391,19 +391,19 @@ func TestPopulateHigherValuesIfHierarchy(t *testing.T) { valueSecret := &policy.Value{ Fqn: exampleSecretFQN, - SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "secret", []*policy.Action{actionRead}, ".test", []string{"value"})}, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "secret", []*policy.Action{actionRead}, ".test", []string{"value"}, nil)}, } valueRestricted := &policy.Value{ Fqn: exampleRestrictedFQN, - SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "restricted", []*policy.Action{actionRead}, ".test", []string{"somethingelse"})}, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "restricted", []*policy.Action{actionRead}, ".test", []string{"somethingelse"}, nil)}, } valueConf := &policy.Value{ Fqn: exampleConfidentialFQN, - SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleConfidentialFQN, "confidential", []*policy.Action{actionRead}, ".hello", []string{"world"})}, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleConfidentialFQN, "confidential", []*policy.Action{actionRead}, ".hello", []string{"world"}, nil)}, } valuePublic := &policy.Value{ Fqn: examplePublicFQN, - SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(examplePublicFQN, "public", []*policy.Action{actionRead}, ".goodnight", []string{"moon"})}, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(examplePublicFQN, "public", []*policy.Action{actionRead}, ".goodnight", []string{"moon"}, nil)}, } values := []*policy.Value{valueSecret, valueRestricted, valueConf, valuePublic} diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 00824ed5f1..d90addfd4a 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -50,6 +50,7 @@ func NewJustInTimePDP( sdk *otdfSDK.SDK, store EntitlementPolicyStore, allowDirectEntitlements bool, + namespacedPolicy bool, ) (*JustInTimePDP, error) { var err error @@ -91,7 +92,7 @@ func NewJustInTimePDP( return nil, fmt.Errorf("failed to fetch all obligations: %w", err) } - pdp, err := NewPolicyDecisionPoint(ctx, log, allAttributes, allSubjectMappings, allRegisteredResources, allowDirectEntitlements) + pdp, err := NewPolicyDecisionPoint(ctx, log, allAttributes, allSubjectMappings, allRegisteredResources, allowDirectEntitlements, namespacedPolicy) if err != nil { return nil, fmt.Errorf("failed to create new policy decision point: %w", err) } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 06979e01c7..fbffcb7037 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -61,6 +61,7 @@ type PolicyDecisionPoint struct { allRegisteredResourceValuesByFQN map[string]*policy.RegisteredResourceValue allAttributesByDefinitionFQN map[string]*policy.Attribute allowDirectEntitlements bool + namespacedPolicy bool } var ( @@ -82,6 +83,7 @@ func NewPolicyDecisionPoint( allSubjectMappings []*policy.SubjectMapping, allRegisteredResources []*policy.RegisteredResource, allowDirectEntitlements bool, + namespacedPolicy bool, ) (*PolicyDecisionPoint, error) { var err error @@ -124,6 +126,18 @@ func NewPolicyDecisionPoint( ) continue } + + if namespacedPolicy { + ns := sm.GetNamespace() + if ns == nil || (ns.GetId() == "" && ns.GetFqn() == "") { + l.TraceContext(ctx, + "unnamespaced subject mapping in strict namespaced-policy mode - skipping", + slog.Any("subject_mapping", sm), + ) + continue + } + } + mappedValue := sm.GetAttributeValue() mappedValueFQN := mappedValue.GetFqn() if _, ok := allEntitleableAttributesByValueFQN[mappedValueFQN]; ok { @@ -169,6 +183,7 @@ func NewPolicyDecisionPoint( allRegisteredResourceValuesByFQN, allAttributesByDefinitionFQN, allowDirectEntitlements, + namespacedPolicy, } return pdp, nil } @@ -230,7 +245,7 @@ func (p *PolicyDecisionPoint) GetDecision( } for idx, resource := range resources { - resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource) + resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource, p.namespacedPolicy) if err != nil || resourceDecision == nil { return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } @@ -308,7 +323,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( } for idx, resource := range resources { - resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource) + resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource, p.namespacedPolicy) if err != nil || resourceDecision == nil { return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 4c873351c5..8853274084 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -283,6 +283,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead}, ".properties.clearance", []string{"ts"}, + nil, ) s.fixtures.secretMapping = createSimpleSubjectMapping( @@ -291,6 +292,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionUpdate}, ".properties.clearance", []string{"secret"}, + nil, ) s.fixtures.confidentialMapping = createSimpleSubjectMapping( @@ -299,6 +301,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead}, ".properties.clearance", []string{"confidential"}, + nil, ) s.fixtures.publicMapping = createSimpleSubjectMapping( @@ -307,6 +310,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead}, ".properties.clearance", []string{"public"}, + nil, ) s.fixtures.engineeringMapping = createSimpleSubjectMapping( @@ -315,6 +319,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionCreate}, ".properties.department", []string{"engineering"}, + nil, ) s.fixtures.financeMapping = createSimpleSubjectMapping( @@ -323,6 +328,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionUpdate}, ".properties.department", []string{"finance"}, + nil, ) s.fixtures.rndMapping = createSimpleSubjectMapping( @@ -331,6 +337,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionUpdate}, ".properties.department", []string{"rnd"}, + nil, ) s.fixtures.usaMapping = createSimpleSubjectMapping( @@ -339,6 +346,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead}, ".properties.country[]", []string{"us"}, + nil, ) s.fixtures.ukMapping = createSimpleSubjectMapping( @@ -347,6 +355,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionDelete}, ".properties.country[]", []string{"uk"}, + nil, ) s.fixtures.projectAlphaMapping = createSimpleSubjectMapping( @@ -355,6 +364,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionCreate}, ".properties.project", []string{"alpha"}, + nil, ) s.fixtures.platformCloudMapping = createSimpleSubjectMapping( @@ -363,6 +373,7 @@ func (s *PDPTestSuite) SetupTest() { []*policy.Action{testActionRead, testActionDelete}, ".properties.platform", []string{"cloud"}, + nil, ) // Initialize registered resources @@ -858,7 +869,7 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint() { for _, tc := range tests { s.Run(tc.name, func() { pdp, err := NewPolicyDecisionPoint( - s.T().Context(), s.logger, tc.attributes, tc.subjectMappings, tc.registeredResources, allowDirectEntitlements) + s.T().Context(), s.logger, tc.attributes, tc.subjectMappings, tc.registeredResources, allowDirectEntitlements, false) if tc.expectError { s.Require().Error(err) @@ -886,6 +897,7 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint_AllowsAttributeDefinitionsWith []*policy.SubjectMapping{f.secretMapping}, nil, allowDirectEntitlements, + false, ) s.Require().NoError(err) @@ -907,6 +919,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping, f.confidentialMapping, f.publicMapping, f.rndMapping, f.engineeringMapping, f.financeMapping}, []*policy.RegisteredResource{f.classificationRegRes, f.deptRegRes}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -1387,6 +1400,7 @@ func (s *PDPTestSuite) Test_GetDecision_ReturnsDecisionRelatedEntitlements() { []*policy.SubjectMapping{f.topSecretMapping, f.engineeringMapping}, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -1449,6 +1463,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []*policy.Action{testActionRead, testActionPrint}, ".properties.clearance", []string{"confidential"}, + nil, ) readConfidentialRegRes := &policy.RegisteredResource{ @@ -1479,6 +1494,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { }, ".properties.clearance", []string{"public"}, + nil, ) // Create a view mapping for Project Alpha with view being a parent action of read and list @@ -1488,6 +1504,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []*policy.Action{testActionView, actionCreate, actionRead}, ".properties.project", []string{"alpha"}, + nil, ) // Create a PDP with relevant attributes and mappings @@ -1504,6 +1521,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { readConfidentialRegRes, f.countryRegRes, }, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -1623,6 +1641,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []*policy.Action{testActionRead}, // Only read is allowed ".properties.clearance", []string{"restricted"}, + nil, ) restrictedRegRes := &policy.RegisteredResource{ Name: "confidential-restricted", @@ -1649,6 +1668,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { []*policy.SubjectMapping{allActionsPublicMapping, restrictedMapping}, []*policy.RegisteredResource{f.classificationRegRes, restrictedRegRes}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(classificationPDP) @@ -1767,6 +1787,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -2089,6 +2110,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { []*policy.Action{testActionRead, testActionUpdate}, ".properties.project", []string{"beta"}, + nil, ) gammaMapping := createSimpleSubjectMapping( @@ -2097,6 +2119,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { []*policy.Action{testActionRead, testActionCreate, testActionDelete}, ".properties.project", []string{"gamma"}, + nil, ) onPremMapping := createSimpleSubjectMapping( @@ -2105,6 +2128,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { []*policy.Action{testActionRead, testActionUpdate}, ".properties.platform", []string{"onprem"}, + nil, ) hybridMapping := createSimpleSubjectMapping( @@ -2113,6 +2137,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { []*policy.Action{testActionRead, testActionCreate, testActionUpdate, testActionDelete}, ".properties.platform", []string{"hybrid"}, + nil, ) // Create a PDP with attributes and mappings from all namespaces @@ -2128,6 +2153,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { }, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -2408,6 +2434,331 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { }) } +// Legacy behavior regression: without strict namespaced-policy enforcement, +// action matching remains name-based and cross-namespace action names still match. +func (s *PDPTestSuite) Test_GetDecision_LegacyBehavior_AllowsCrossNamespaceActionNameMatch() { + f := s.fixtures + + baseNS := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://" + testBaseNamespace} + secondaryNS := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://" + testSecondaryNamespace} + + f.classificationAttr.Namespace = baseNS + f.projectAttr.Namespace = secondaryNS + + secretMapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.clearance", + []string{"secret"}, + baseNS, + ) + + projectAlphaWrongNamespace := createSimpleSubjectMapping( + testProjectAlphaFQN, + "alpha", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.project", + []string{"alpha"}, + secondaryNS, + ) + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.projectAttr}, + []*policy.SubjectMapping{secretMapping, projectAlphaWrongNamespace}, + []*policy.RegisteredResource{}, + allowDirectEntitlements, + false, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("legacy-cross-ns-action-match", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + }) + + resource := createAttributeValueResource("mixed-namespaces-legacy", testClassSecretFQN, testProjectAlphaFQN) + + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.True(decision.AllPermitted, "legacy behavior should remain name-based until strict namespaced-policy mode is enabled") +} + +// Desired strict-mode behavior (NamespacedPolicy=true): +// if the same action name is only entitled in a different namespace than the evaluated value, +// decisioning must fail closed. +func (s *PDPTestSuite) Test_GetDecision_StrictMode_DeniesCrossNamespaceActionMatch() { + f := s.fixtures + + baseNS := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://" + testBaseNamespace} + secondaryNS := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://" + testSecondaryNamespace} + + f.classificationAttr.Namespace = baseNS + f.projectAttr.Namespace = secondaryNS + + secretMapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.clearance", + []string{"secret"}, + baseNS, + ) + + projectAlphaWrongNamespace := createSimpleSubjectMapping( + testProjectAlphaFQN, + "alpha", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.project", + []string{"alpha"}, + secondaryNS, + ) + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.projectAttr}, + []*policy.SubjectMapping{secretMapping, projectAlphaWrongNamespace}, + []*policy.RegisteredResource{}, + allowDirectEntitlements, + true, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("cross-ns-action-mismatch", map[string]interface{}{ + "clearance": "secret", + "project": "alpha", + }) + + resource := createAttributeValueResource("mixed-namespaces", testClassSecretFQN, testProjectAlphaFQN) + + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.False(decision.AllPermitted, "desired strict namespaced behavior: action should not match across namespaces") +} + +func (s *PDPTestSuite) Test_GetDecision_StrictMode_SkipsUnnamespacedSubjectMappings() { + f := s.fixtures + + baseNS := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://" + testBaseNamespace} + f.classificationAttr.Namespace = baseNS + + unnamespacedMapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.clearance", + []string{"secret"}, + nil, + ) + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr}, + []*policy.SubjectMapping{unnamespacedMapping}, + []*policy.RegisteredResource{}, + allowDirectEntitlements, + true, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("strict-skip-unnamespaced-sm", map[string]interface{}{ + "clearance": "secret", + }) + + resource := createAttributeValueResource("strict-skip-unnamespaced-sm-resource", testClassSecretFQN) + + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.AllPermitted, "strict namespaced-policy mode should skip unnamespaced subject mappings") +} + +func (s *PDPTestSuite) Test_GetDecision_LegacyBehavior_UsesUnnamespacedSubjectMappings() { + f := s.fixtures + + baseNS := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://" + testBaseNamespace} + f.classificationAttr.Namespace = baseNS + + unnamespacedMapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.clearance", + []string{"secret"}, + nil, + ) + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr}, + []*policy.SubjectMapping{unnamespacedMapping}, + []*policy.RegisteredResource{}, + allowDirectEntitlements, + false, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("legacy-uses-unnamespaced-sm", map[string]interface{}{ + "clearance": "secret", + }) + + resource := createAttributeValueResource("legacy-uses-unnamespaced-sm-resource", testClassSecretFQN) + + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.AllPermitted, "legacy mode should continue evaluating unnamespaced subject mappings") +} + +func (s *PDPTestSuite) Test_GetDecision_StrictMode_UsesOnlyNamespacedSubjectMappings() { + f := s.fixtures + + baseNS := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://" + testBaseNamespace} + f.classificationAttr.Namespace = baseNS + + unnamespacedMapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{{Name: actions.ActionNameRead, Namespace: baseNS}}, + ".properties.clearance", + []string{"secret"}, + nil, + ) + + namespacedMapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{{Name: actions.ActionNameCreate, Namespace: baseNS}}, + ".properties.clearance", + []string{"secret"}, + baseNS, + ) + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr}, + []*policy.SubjectMapping{unnamespacedMapping, namespacedMapping}, + []*policy.RegisteredResource{}, + allowDirectEntitlements, + true, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("strict-namespaced-only-sm", map[string]interface{}{ + "clearance": "secret", + }) + + resource := createAttributeValueResource("strict-namespaced-only-sm-resource", testClassSecretFQN) + + readDecision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) + s.Require().NoError(err) + s.Require().NotNil(readDecision) + s.False(readDecision.AllPermitted, "strict mode should ignore unnamespaced read mapping") + + createDecision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{resource}) + s.Require().NoError(err) + s.Require().NotNil(createDecision) + s.True(createDecision.AllPermitted, "strict mode should keep namespaced mappings") +} + +func (s *PDPTestSuite) Test_GetDecision_StrictMode_RequestActionIdentityMatrix() { + f := s.fixtures + + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://" + testBaseNamespace} + namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://" + testSecondaryNamespace} + + f.classificationAttr.Namespace = namespaceA + + const ( + idNamespaceARead = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + idNamespaceBRead = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ) + + mapping := createSimpleSubjectMapping( + testClassSecretFQN, + "secret", + []*policy.Action{ + {Id: idNamespaceARead, Name: actions.ActionNameRead, Namespace: namespaceA}, + }, + ".properties.clearance", + []string{"secret"}, + namespaceA, + ) + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr}, + []*policy.SubjectMapping{mapping}, + []*policy.RegisteredResource{}, + allowDirectEntitlements, + true, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("strict-action-identity-matrix", map[string]interface{}{ + "clearance": "secret", + }) + resource := createAttributeValueResource("strict-action-identity-matrix-resource", testClassSecretFQN) + + tests := []struct { + name string + action *policy.Action + permitted bool + }{ + { + name: "id match in required namespace permits", + action: &policy.Action{Id: idNamespaceARead, Name: actions.ActionNameRead}, + permitted: true, + }, + { + name: "id match in wrong namespace denies", + action: &policy.Action{Id: idNamespaceBRead, Name: actions.ActionNameRead}, + permitted: false, + }, + { + name: "name plus matching namespace permits", + action: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, + permitted: true, + }, + { + name: "name plus wrong namespace denies", + action: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceB}, + permitted: false, + }, + { + name: "name only resolves contextually and permits", + action: &policy.Action{Name: actions.ActionNameRead}, + permitted: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + decision, _, decisionErr := pdp.GetDecision(s.T().Context(), entity, tt.action, []*authz.Resource{resource}) + s.Require().NoError(decisionErr) + s.Require().NotNil(decision) + s.Equal(tt.permitted, decision.AllPermitted) + }) + } +} + func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { f := s.fixtures @@ -2482,6 +2833,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping, f.confidentialMapping, f.publicMapping, f.engineeringMapping, f.financeMapping}, []*policy.RegisteredResource{f.networkRegRes, regResS3BucketEntity}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -2656,6 +3008,7 @@ func (s *PDPTestSuite) Test_GetEntitlements() { }, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -2912,6 +3265,7 @@ func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { []*policy.Action{testActionRead}, ".properties.levels[]", []string{"top"}, + nil, ) upperMiddleMapping := createSimpleSubjectMapping( upperMiddleValueFQN, @@ -2919,6 +3273,7 @@ func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { []*policy.Action{testActionCreate}, ".properties.levels[]", []string{"upper-middle"}, + nil, ) middleMapping := createSimpleSubjectMapping( middleValueFQN, @@ -2926,6 +3281,7 @@ func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { []*policy.Action{testActionUpdate, {Name: actionNameTransmit}}, ".properties.levels[]", []string{"middle"}, + nil, ) lowerMiddleMapping := createSimpleSubjectMapping( lowerMiddleValueFQN, @@ -2933,6 +3289,7 @@ func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { []*policy.Action{testActionDelete}, ".properties.levels[]", []string{"lower-middle"}, + nil, ) bottomMapping := createSimpleSubjectMapping( bottomValueFQN, @@ -2940,6 +3297,7 @@ func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { []*policy.Action{{Name: customActionGather}}, ".properties.levels[]", []string{"bottom"}, + nil, ) // Create a PDP with the hierarchy attribute and mappings @@ -2956,6 +3314,7 @@ func (s *PDPTestSuite) Test_GetEntitlements_AdvancedHierarchy() { }, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -3035,6 +3394,7 @@ func (s *PDPTestSuite) Test_GetEntitlementsRegisteredResource() { []*policy.SubjectMapping{}, []*policy.RegisteredResource{f.regRes}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -3273,8 +3633,8 @@ func createSimpleSubjectConditionSet(selector string, values []string) *policy.S } // createSimpleSubjectMapping creates a complete subject mapping with a simple condition -func createSimpleSubjectMapping(attrValueFQN string, attrValue string, actions []*policy.Action, selector string, values []string) *policy.SubjectMapping { - return &policy.SubjectMapping{ +func createSimpleSubjectMapping(attrValueFQN string, attrValue string, actions []*policy.Action, selector string, values []string, namespace *policy.Namespace) *policy.SubjectMapping { + mapping := &policy.SubjectMapping{ AttributeValue: &policy.Value{ Fqn: attrValueFQN, Value: attrValue, @@ -3282,6 +3642,11 @@ func createSimpleSubjectMapping(attrValueFQN string, attrValue string, actions [ SubjectConditionSet: createSimpleSubjectConditionSet(selector, values), Actions: actions, } + if namespace != nil { + mapping.Namespace = namespace + mapping.SubjectConditionSet.Namespace = namespace + } + return mapping } // Helper function to test decision results @@ -3311,6 +3676,7 @@ func (s *PDPTestSuite) Test_GetDecision_NonExistentAttributeFQN() { []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping}, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -3414,6 +3780,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialFQNsInResource() { []*policy.SubjectMapping{f.secretMapping, f.topSecretMapping, f.confidentialMapping}, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -3462,6 +3829,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_NonExistentFQN() { []*policy.SubjectMapping{f.secretMapping}, []*policy.RegisteredResource{f.classificationRegRes}, // Only classification registered resource allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -3547,6 +3915,7 @@ func (s *PDPTestSuite) Test_GetDecision_NoPolicies() { []*policy.SubjectMapping{}, []*policy.RegisteredResource{}, allowDirectEntitlements, + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) @@ -3630,6 +3999,7 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { []*policy.SubjectMapping{}, []*policy.RegisteredResource{}, true, // Allow direct entitlements + false, ) s.Require().NoError(err) s.Require().NotNil(pdp) diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go index 491d1bb8f7..b0cb55025f 100644 --- a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go +++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go @@ -2,6 +2,7 @@ package subjectmappingbuiltin import ( "fmt" + "log/slog" "maps" "slices" "strings" @@ -71,10 +72,28 @@ func EvaluateSubjectMappingsWithActions( } actions := subjectMapping.GetActions() - // Cache each action by name to deduplicate + // Cache each action by name to deduplicate. + // In normal operation, same-name conflicting actions should be prevented + // earlier by policy service create/update validation, which enforces + // namespace consistency between subject mappings and referenced actions. + // This extra conflict check is defensive for unexpected or mixed legacy + // states in-memory; if encountered, keep deterministic behavior and log. m := make(map[string]*policy.Action, len(actions)) for _, action := range actions { - m[strings.ToLower(action.GetName())] = action + key := strings.ToLower(action.GetName()) + if existing, ok := m[key]; ok { + if actionsConflict(existing, action) { + slog.Warn( + "subject mapping action name collision with conflicting identity; using deterministic preference", + slog.String("action_name", key), + slog.Any("existing_action", existing), + slog.Any("candidate_action", action), + ) + } + m[key] = preferAction(existing, action) + continue + } + m[key] = action } entitlementsSet[valueFQN] = append(entitlementsSet[valueFQN], slices.Collect(maps.Values(m))...) } @@ -84,3 +103,58 @@ func EvaluateSubjectMappingsWithActions( return entitlementsSet, nil } + +func actionsConflict(existing *policy.Action, candidate *policy.Action) bool { + if existing == nil || candidate == nil { + return false + } + + if existing.GetId() != candidate.GetId() { + return true + } + + existingNS := existing.GetNamespace() + candidateNS := candidate.GetNamespace() + if existingNS == nil && candidateNS == nil { + return false + } + if (existingNS == nil) != (candidateNS == nil) { + return true + } + + if existingNS.GetId() != candidateNS.GetId() { + return true + } + + return !strings.EqualFold(existingNS.GetFqn(), candidateNS.GetFqn()) +} + +func preferAction(existing *policy.Action, candidate *policy.Action) *policy.Action { + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + + if existing.GetId() == "" && candidate.GetId() != "" { + return candidate + } + if existing.GetId() != "" && candidate.GetId() == "" { + return existing + } + + existingNS := existing.GetNamespace() + candidateNS := candidate.GetNamespace() + existingHasNS := existingNS != nil && (existingNS.GetId() != "" || existingNS.GetFqn() != "") + candidateHasNS := candidateNS != nil && (candidateNS.GetId() != "" || candidateNS.GetFqn() != "") + + if !existingHasNS && candidateHasNS { + return candidate + } + if existingHasNS && !candidateHasNS { + return existing + } + + return existing +} diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go index 2b5a86c1a4..2ed38c1e9e 100644 --- a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go +++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go @@ -450,6 +450,37 @@ func TestEvaluateSubjectMappingsWithActions_MultipleMatchingSubjectMappings(t *t assert.Equal(t, actions.ActionNameRead, internalActions[0].GetName()) } +func TestEvaluateSubjectMappingsWithActions_DeduplicatesConflictingActionNamesDeterministically(t *testing.T) { + entity := createEntityRepresentation("eng-entity", map[string]interface{}{ + "department": "engineering", + }) + + ns := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://example.com"} + + sm := &policy.SubjectMapping{ + SubjectConditionSet: departmentEngineeringSM.GetSubjectConditionSet(), + Actions: []*policy.Action{ + {Name: actions.ActionNameRead}, + {Id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Name: actions.ActionNameRead, Namespace: ns}, + }, + } + + attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + classConfFQN: createAttributeMapping(classConfFQN, sm), + } + + entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, entity) + require.NoError(t, err) + + actionsList, exists := entitlements[classConfFQN] + require.True(t, exists) + require.Len(t, actionsList, 1) + + assert.Equal(t, actions.ActionNameRead, actionsList[0].GetName()) + assert.Equal(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", actionsList[0].GetId()) + assert.Equal(t, ns.GetId(), actionsList[0].GetNamespace().GetId()) +} + func TestEvaluateSubjectMappingsWithActions_NoMatchingSubjectMappings(t *testing.T) { // Setup test data with entity that doesn't match any subject mappings marketingEntity := createEntityRepresentation("marketing-entity", map[string]interface{}{ From 5d1dfd1d7a9c3ef2f22feeab09a66d676cdce1cc Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 00:19:43 -0400 Subject: [PATCH 03/39] add cukes --- .../platform.namespaced_policy.template | 57 ++++++++++++++++++ .../features/namespaced-decisioning.feature | 59 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 tests-bdd/cukes/resources/platform.namespaced_policy.template create mode 100644 tests-bdd/features/namespaced-decisioning.feature diff --git a/tests-bdd/cukes/resources/platform.namespaced_policy.template b/tests-bdd/cukes/resources/platform.namespaced_policy.template new file mode 100644 index 0000000000..8a5edc0515 --- /dev/null +++ b/tests-bdd/cukes/resources/platform.namespaced_policy.template @@ -0,0 +1,57 @@ +authEndpoint: &authEndpoint http://{{ .hostname }}:{{.kcPort }}/auth +issuerEndpoint: &issuerEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}} +tokenEndpoint: &tokenEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}}/protocol/openid-connect/token +entityResolutionServiceUrl: &entityResolutionServiceUrl https://{{ .hostname }}:{{.platformPort }}/entityresolution/resolve +platformEndpoint: &platformEndpoint https://{{.hostname }}:{{.platformPort }} +authRealm: &authRealm {{.authRealm}} +mode: all +logger: + level: debug + type: text + output: stdout +server: + port: {{.platformPort}} + auth: + enabled: true + enforceDPoP: false + audience: *platformEndpoint + issuer: *issuerEndpoint + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard +db: + host: {{ .pgHost }} + port: {{ .pgPort }} + database: {{ .pgDatabase }} + user: postgres + password: changeme + schema: otdf +services: + authorization: + namespaced_policy: true + kas: + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: r1 + alg: rsa:2048 + entityresolution: + url: *authEndpoint + clientid: 'tdf-entity-resolution' + clientsecret: 'secret' + realm: *authRealm + legacykeycloak: true + inferid: + from: + email: true + username: true + shared: + clientId: otdf-shared + clientSecret: secret + authClientId: otdf-shared-auth + serviceHostName: shared + platformEndpoint: *platformEndpoint + platformAuthEndpoint: *authEndpoint + platformAuthRealm: *authRealm + tokenEndpoint: *tokenEndpoint diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature new file mode 100644 index 0000000000..b647a948e0 --- /dev/null +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -0,0 +1,59 @@ +@authorization @namespaced-decisioning +Feature: Namespaced Policy Decisioning (name-only action requests) + Validate strict namespaced decisioning behavior for action-name requests + using both standard and custom actions. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | clearance | ["TS"] | + And a local platform with platform template "cukes/resources/platform.namespaced_policy.template" and keycloak template "cukes/resources/keycloak_base.template" + And I submit a request to create a namespace with name "ns-one.example.com" and reference id "ns1" + And I submit a request to create a namespace with name "ns-two.example.com" and reference id "ns2" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | classification | hierarchy | topsecret | + | ns2 | classification | hierarchy | topsecret | + Then the response should be successful + And a condition group referenced as "cg_ts_clearance" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.clearance[] | in | TS | + And a subject set referenced as "ss_ts_clearance" containing the condition groups "cg_ts_clearance" + And I send a request to create a subject condition set referenced as "scs_clearance_topsecret" containing subject sets "ss_ts_clearance" + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: Standard action name permits when entitled in resource namespace + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_read_ts | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Standard action name denies when entitled only in different namespace + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_read_ts | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Custom action name permits when entitled in resource namespace + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_export_ts | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Custom action name denies when entitled only in different namespace + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_export_ts | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "DENY" decision response From 3441f0d6bde1d6daf6edb020fb291913df9d7d1a Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 00:24:22 -0400 Subject: [PATCH 04/39] more cukes scenarios --- tests-bdd/cukes/steps_subjectmappings.go | 8 ++++ .../features/namespaced-decisioning.feature | 48 +++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/tests-bdd/cukes/steps_subjectmappings.go b/tests-bdd/cukes/steps_subjectmappings.go index 7f2c24fe6a..a7ec56da1b 100644 --- a/tests-bdd/cukes/steps_subjectmappings.go +++ b/tests-bdd/cukes/steps_subjectmappings.go @@ -29,6 +29,14 @@ func (s *SubjectMappingsStepDefinitions) iSendARequestToCreateSubjectMapping(ctx cellIndexMap[ci] = c.Value } else { switch cellIndexMap[ci] { + case "namespace_id": + nsID, ok := scenarioContext.GetObject(strings.TrimSpace(c.Value)).(string) + if !ok { + return ctx, fmt.Errorf("unable to get namespace id for %s", c.Value) + } + subjectMappingRequest.NamespaceId = nsID + case "namespace_fqn": + subjectMappingRequest.NamespaceFqn = strings.TrimSpace(c.Value) case "attribute_value": av, err := scenarioContext.GetAttributeValue(ctx, strings.TrimSpace(c.Value)) if err != nil { diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index b647a948e0..5ad9faed30 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -24,8 +24,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Standard action name permits when entitled in resource namespace And I send a request to create a subject mapping with: - | reference_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_read_ts | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -33,8 +33,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Standard action name denies when entitled only in different namespace And I send a request to create a subject mapping with: - | reference_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_read_ts | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_read_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -42,8 +42,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Custom action name permits when entitled in resource namespace And I send a request to create a subject mapping with: - | reference_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_export_ts | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -51,9 +51,41 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Custom action name denies when entitled only in different namespace And I send a request to create a subject mapping with: - | reference_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_export_ts | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_export_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "DENY" decision response + + Scenario: Standard action AND behavior across mixed namespaces + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "DENY" decision response + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_read_ts2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Custom action AND behavior across mixed namespaces + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "DENY" decision response + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_export_ts_2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + Then the response should be successful + And I should get a "PERMIT" decision response From 3ca75543d6ccc3d4e4cd60066fe0470828fa798c Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 10:50:10 -0400 Subject: [PATCH 05/39] lint, attempt to fix bdd --- ...paced-subject-mappings-decisioning-plan.md | 208 ++++++++++++++++++ service/internal/access/v2/evaluate.go | 12 +- service/internal/access/v2/evaluate_test.go | 3 +- tests-bdd/cukes/steps_subjectmappings.go | 23 +- .../features/namespaced-decisioning.feature | 35 +-- 5 files changed, 254 insertions(+), 27 deletions(-) create mode 100644 docs/namespaced-subject-mappings-decisioning-plan.md diff --git a/docs/namespaced-subject-mappings-decisioning-plan.md b/docs/namespaced-subject-mappings-decisioning-plan.md new file mode 100644 index 0000000000..9ad95b46bb --- /dev/null +++ b/docs/namespaced-subject-mappings-decisioning-plan.md @@ -0,0 +1,208 @@ +# Namespaced Subject Mappings: Decisioning Plan + +## Agreed Behavior + +- `NamespacedPolicy=false` => keep current legacy behavior (no namespace filtering at SM load time; existing name-based matching behavior remains). +- `NamespacedPolicy=true` => enforce namespaced subject mapping/action evaluation. +- For a single resource containing attribute values from multiple namespaces, keep existing `AND` behavior. +- If requested action is missing in any required namespace, fail closed. +- Standard action existence is checked lazily at evaluation time (no startup invariant). + +## Current Baseline (Verified) + +- Existing PDP decisioning for a single resource with multi-namespace attributes behaves as `AND` at data-rule level. + - Example coverage: `service/internal/access/v2/pdp_test.go:2344` and follow-up assertions. +- Current action checks are primarily name-based in evaluation paths. + - RR AAV filtering: `service/internal/access/v2/evaluate.go` (`getResourceDecision`) + - Rule checks: `allOfRule`, `anyOfRule`, `hierarchyRule` in `service/internal/access/v2/evaluate.go` + +## Design Decision + +Per-namespace action resolution is done during entitlement/rule evaluation (not request parsing). + +- Request action remains unnamespaced (`action.name` from request). +- Resolution context is the namespace of the value/SM/rule currently being evaluated. +- Match requires: + - name equality, and + - namespace equality (when `NamespacedPolicy=true`). + +This keeps decisioning context-local and avoids leaking cross-namespace action matches. + +## Request Action Semantics + +`GetDecisionRequest` currently carries `policy.Action`. We will support three request-action shapes with explicit precedence: + +1. `action.id` provided (most specific) + - Resolve exact action by ID. + - Treat resolved action namespace as authoritative. + - If ID is missing/invalid for the required evaluation context, fail closed. +2. `action.name` + `action.namespace` provided + - Resolve by name within provided namespace. + - If not found in required evaluation context, fail closed. +3. `action.name` only (least specific) + - Resolve contextually per evaluated namespace (value/rule namespace). + - In strict mode, this remains namespace-aware at evaluation time. + +### Precedence Rule + +- If `id` is present, use `id` semantics (ignore name-only fallback behavior). +- Else if `name+namespace` present, use scoped-name semantics. +- Else use name-only contextual semantics. + +## Implementation Plan (Detailed) + +### 1) Thread feature flag into PDP runtime + +Code touchpoints: + +- `service/internal/access/v2/pdp.go` + +Changes: + +- Add `namespacedPolicy bool` field on `PolicyDecisionPoint`. +- Extend `NewPolicyDecisionPoint(...)` to accept/set the flag. +- Thread flag from policy/access service construction into PDP instantiation sites. + +### 2) Filter SMs at PDP load time (strict mode only) + +Code touchpoint: + +- `service/internal/access/v2/pdp.go` inside `NewPolicyDecisionPoint` loop over `allSubjectMappings`. + +Changes: + +- In namespaced mode, skip SMs without namespace. +- In legacy mode, keep current behavior (no additional filtering introduced by this work). +- Keep validation + warning log on skipped/invalid SMs. + +Reason: + +- Enforces clean mode split once and prevents mixed-evaluation edge cases downstream. + +### 3) Add namespace-aware action match helper + +Code touchpoint: + +- `service/internal/access/v2/evaluate.go` + +Add helper (shape example): + +- `isRequestedActionMatch(requestedActionName string, requiredNamespaceID string, entitledAction *policy.Action, namespacedPolicy bool) bool` + +Behavior: + +- `namespacedPolicy=false`: preserve existing behavior (case-insensitive name match; no new namespace gating in this change). +- `namespacedPolicy=true`: case-insensitive name match and entitled action namespace must equal `requiredNamespaceID`. + +Request-shape integration: + +- `id` path: require exact ID match, then namespace consistency in strict mode. +- `name+namespace` path: require namespace-aware scoped name match. +- `name` path: namespace-aware contextual matching only when strict mode is enabled. + +### 4) Apply helper in all action comparison paths + +Code touchpoints: + +- `service/internal/access/v2/evaluate.go` + - RR AAV filtering in `getResourceDecision` + - `allOfRule` + - `anyOfRule` + - `hierarchyRule` + +Changes: + +- Replace name-only checks with helper-based namespace-aware checks. + +### 5) Carry namespace context into rule checks + +Problem: + +- Current rule functions use value FQNs and entitlements, but action checks don’t receive explicit namespace context per value. + +Plan: + +- Derive required namespace from `accessibleAttributeValues[valueFQN].Attribute.Namespace` (or value namespace if needed). +- Pass `requiredNamespaceID` into action match checks for each evaluated value FQN. + +### 6) Fail-closed semantics for missing action in required namespace + +Behavior: + +- If any required value/rule cannot find matching action in that namespace, rule fails. +- For multi-namespace resource under `AND`, one failure denies the resource decision. +- For explicit action identity (`id` or `name+namespace`), decisioning does not fall back to looser matching; it denies only when that explicit identity is unresolved or mismatched for the evaluated namespace context. + +Notes: + +- This naturally aligns with existing `allOfRule`/resource result aggregation model. + +### 7) Keep lazy standard-action behavior + +Behavior: + +- No startup seed validation required. +- If standard/custom action is missing in a required namespace at evaluation time, decision fails closed. + +### 8) Logging/audit improvements + +Add targeted debug/warn logs where mismatch/failure occurs: + +- requested action name +- required namespace +- candidate action namespace (if any) +- rule/value FQN context + +This will reduce debugging time when rollouts begin. + +## Test Plan (Detailed) + +### Unit/logic tests + +1. `NamespacedPolicy=false` + - legacy behavior unchanged (no strict namespace filtering introduced) + - regression checks for existing name-based matching behavior +2. `NamespacedPolicy=true` + - unnamespaced SM ignored + - namespaced SM evaluated +3. Namespace-aware action matching helper + - match name+namespace succeeds + - name match with wrong namespace fails + - legacy mode keeps existing name-based behavior +4. Request action shape precedence + - `id` beats `name` + - `name+namespace` is scoped + - `name` only is contextual + +### PDP behavior tests + +0. Legacy-mode regression (`NamespacedPolicy=false`) + - mixed namespaced + unnamespaced policy data continues to evaluate with current behavior (no strict namespace gating introduced) + +1. Multi-namespace attrs in one resource, action present in all namespaces => permit +2. Multi-namespace attrs in one resource, action missing in one namespace => deny +3. RR AAV path respects namespace-aware action match (not name-only) +4. Existing `AND` behavior remains unchanged outside namespace-aware action filtering +5. Action request-shape matrix (`id`, `name+namespace`, `name`) across both GetDecision APIs + +### Regression safety + +- Re-run existing decisioning and obligations PDP suites to ensure no unintended semantic shifts. + +## Rollout Plan + +1. Land code behind `NamespacedPolicy` flag. +2. Keep default behavior (`false`) in environments until migration complete. +3. Validate policy data readiness (SM/SCS/actions namespaced) before flip. +4. Flip to `true`; monitor deny/mismatch logs. +5. Remove compatibility branch once migration is complete and stable. + +## Open Questions + +- Exact expected error/log wording when action is missing in required namespace. +- Whether to expose namespace-mismatch reason in decision debug output/API diagnostics. + +## Non-Goals + +- Changing obligation aggregation semantics. +- Introducing mixed-mode fallback (`evaluate both namespaced + unnamespaced SM`) once flag logic is strict. diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 31f392d2af..a20e869ca6 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -255,13 +255,13 @@ func evaluateDefinition( // 1. For each resource attribute value FQN, the action is entitled // 2. If any FQN is not entitled, or the FQN is missing the requested action, the rule fails func allOfRule( - _ context.Context, - _ *logger.Logger, + ctx context.Context, + l *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, ) []EntitlementFailure { - return allOfRuleWithContext(nil, nil, entitlements, action, resourceValueFQNs, "", false) + return allOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, "", false) } func allOfRuleWithContext( @@ -307,13 +307,13 @@ func allOfRuleWithContext( // 2. If none of the FQNs are found the entitlements, the rule fails // 3. If none of the matching FQNs in the entitlements contain the requested action, the rule fails func anyOfRule( - _ context.Context, - _ *logger.Logger, + ctx context.Context, + l *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, ) []EntitlementFailure { - return anyOfRuleWithContext(nil, nil, entitlements, action, resourceValueFQNs, "", false) + return anyOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, "", false) } func anyOfRuleWithContext( diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index d90ea39709..8075e8a2ed 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -1,7 +1,6 @@ package access import ( - "errors" "strings" "testing" @@ -888,7 +887,7 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition_NamespacedPolicy() { if tc.expectErr != nil { s.Require().Error(err) - s.True(errors.Is(err, tc.expectErr)) + s.ErrorIs(err, tc.expectErr) s.ErrorContains(err, tc.errorContains) return } diff --git a/tests-bdd/cukes/steps_subjectmappings.go b/tests-bdd/cukes/steps_subjectmappings.go index a7ec56da1b..f79eefc650 100644 --- a/tests-bdd/cukes/steps_subjectmappings.go +++ b/tests-bdd/cukes/steps_subjectmappings.go @@ -80,6 +80,14 @@ func (s *SubjectMappingsStepDefinitions) iSendARequestToCreateSubjectMapping(ctx } func (s *SubjectMappingsStepDefinitions) iSendARequestToCreateSubjectConditionSet(ctx context.Context, referenceID string, subjectSetIDs string) (context.Context, error) { + return s.createSubjectConditionSet(ctx, referenceID, subjectSetIDs, "") +} + +func (s *SubjectMappingsStepDefinitions) iSendARequestToCreateSubjectConditionSetInNamespace(ctx context.Context, referenceID string, namespaceRef string, subjectSetIDs string) (context.Context, error) { + return s.createSubjectConditionSet(ctx, referenceID, subjectSetIDs, namespaceRef) +} + +func (s *SubjectMappingsStepDefinitions) createSubjectConditionSet(ctx context.Context, referenceID string, subjectSetIDs string, namespaceRef string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() subjectSets := []*policy.SubjectSet{} @@ -90,11 +98,21 @@ func (s *SubjectMappingsStepDefinitions) iSendARequestToCreateSubjectConditionSe } subjectSets = append(subjectSets, ss) } - resp, respErr := scenarioContext.SDK.SubjectMapping.CreateSubjectConditionSet(ctx, &subjectmapping.CreateSubjectConditionSetRequest{ + req := &subjectmapping.CreateSubjectConditionSetRequest{ SubjectConditionSet: &subjectmapping.SubjectConditionSetCreate{ SubjectSets: subjectSets, }, - }) + } + + if namespaceRef != "" { + nsID, ok := scenarioContext.GetObject(strings.TrimSpace(namespaceRef)).(string) + if !ok { + return ctx, fmt.Errorf("unable to get namespace id for %s", namespaceRef) + } + req.NamespaceId = nsID + } + + resp, respErr := scenarioContext.SDK.SubjectMapping.CreateSubjectConditionSet(ctx, req) if resp != nil { scenarioContext.RecordObject(referenceID, resp.GetSubjectConditionSet()) } @@ -171,5 +189,6 @@ func RegisterSubjectMappingsStepsDefinitions(ctx *godog.ScenarioContext) { ctx.Step(`a condition group referenced as "([^"]*)" with an "([^"]*)" operator with conditions:$`, subjectMappingStepDefinitions.aConditionGroup) ctx.Step(`^a subject set referenced as "([^"]*)" containing the condition groups "([^"]*)"$`, subjectMappingStepDefinitions.aSubjectSet) ctx.Step(`^I send a request to create a subject condition set referenced as "([^"]*)" containing subject sets "([^"]*)"$`, subjectMappingStepDefinitions.iSendARequestToCreateSubjectConditionSet) + ctx.Step(`^I send a request to create a subject condition set referenced as "([^"]*)" in namespace "([^"]*)" containing subject sets "([^"]*)"$`, subjectMappingStepDefinitions.iSendARequestToCreateSubjectConditionSetInNamespace) ctx.Step(`^I send a request to create a subject mapping with:$`, subjectMappingStepDefinitions.iSendARequestToCreateSubjectMapping) } diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index 5ad9faed30..94057e8e95 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -19,13 +19,14 @@ Feature: Namespaced Policy Decisioning (name-only action requests) | selector_value | operator | values | | .attributes.clearance[] | in | TS | And a subject set referenced as "ss_ts_clearance" containing the condition groups "cg_ts_clearance" - And I send a request to create a subject condition set referenced as "scs_clearance_topsecret" containing subject sets "ss_ts_clearance" + And I send a request to create a subject condition set referenced as "scs_clearance_topsecret_ns1" in namespace "ns1" containing subject sets "ss_ts_clearance" + And I send a request to create a subject condition set referenced as "scs_clearance_topsecret_ns2" in namespace "ns2" containing subject sets "ss_ts_clearance" And there is a "user_name" subject entity with value "alice" and referenced as "alice" Scenario: Standard action name permits when entitled in resource namespace And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -33,8 +34,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Standard action name denies when entitled only in different namespace And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_read_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_read_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -42,8 +43,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Custom action name permits when entitled in resource namespace And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -51,8 +52,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Custom action name denies when entitled only in different namespace And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_export_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_export_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -60,15 +61,15 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Standard action AND behavior across mixed namespaces And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "DENY" decision response And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_read_ts2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read | | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_read_ts2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" Then the response should be successful @@ -76,15 +77,15 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Scenario: Custom action AND behavior across mixed namespaces And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "DENY" decision response And I send a request to create a subject mapping with: - | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_export_ts_2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret | | export | + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_ns2_export_ts_2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" Then the response should be successful From 26740fd4f4ff22b3e0a71b990efe8d0ebf424359 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 11:43:49 -0400 Subject: [PATCH 06/39] use require --- service/internal/access/v2/evaluate_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 8075e8a2ed..0a4f3cbd88 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -887,7 +887,7 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition_NamespacedPolicy() { if tc.expectErr != nil { s.Require().Error(err) - s.ErrorIs(err, tc.expectErr) + s.Require().ErrorIs(err, tc.expectErr) s.ErrorContains(err, tc.errorContains) return } From 6278ba41036e26f0dc6d177b294e9ed069e4a2b7 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 12:59:25 -0400 Subject: [PATCH 07/39] code rabbit suggestions --- ...namespaced-subject-mappings-decisioning.md | 16 ++++-- service/internal/access/v2/evaluate.go | 8 +++ service/internal/access/v2/evaluate_test.go | 53 +++++++++++++++++++ service/internal/access/v2/helpers_test.go | 2 +- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md index c0a8b419cb..74f56e8a77 100644 --- a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md +++ b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md @@ -12,7 +12,7 @@ driver: '@elizabethhealy' ## Context and Problem Statement -Policy objects are moving toward strict namespace ownership, but access decisioning still treats actions as unscoped names in several evaluation paths. This creates ambiguity when a request action name exists in multiple namespaces and when a single resource includes attributes from multiple namespaces. +Policy objects are moving toward strict namespace ownership, but access decisioning still treats actions as unscoped names in several evaluation paths. Subject mappings are also transitioning from legacy unnamespaced records to namespace-owned records. This creates ambiguity when a request action name exists in multiple namespaces and when a single resource includes attributes from multiple namespaces. We need a decisioning model that is namespace-correct, fail-closed, and compatible with staged rollout using the `NamespacedPolicy` feature flag. @@ -25,9 +25,9 @@ We need a decisioning model that is namespace-correct, fail-closed, and compatib ## Decision Outcome -Chosen option: **Resolve request action by name within each evaluation namespace context**. +Chosen option: **Resolve request action identity within each evaluation namespace context**. -The request action remains unnamespaced in the decision request. During evaluation, action matching is performed per namespace context (derived from the rule/value being evaluated), not globally. +The normative request-action contract supports three request shapes. During evaluation, matching is always applied per namespace context (derived from the rule/value being evaluated), not globally. Request-action identity precedence is explicit: @@ -40,7 +40,13 @@ When identity is explicit (`id` or `name+namespace`), decisioning does not fall Feature-flag mode split: - `NamespacedPolicy=false`: preserve existing legacy behavior (no new namespace filtering semantics introduced by this change). -- `NamespacedPolicy=true`: enforce namespaced subject mapping evaluation and require action namespace equality for each evaluated namespace. +- `NamespacedPolicy=true`: enforce namespaced subject mapping evaluation (unnamespaced SMs are ignored) and require action namespace equality for each evaluated namespace. + +Subject mapping namespace enforcement (strict mode): + +- Subject mapping namespace must match the namespace of the referenced attribute value. +- Subject mapping namespace must match the namespace of the referenced subject condition set. +- Name-based action matching is evaluated in the same namespace context as the SM/value under evaluation. For multi-namespace resources, existing `AND` semantics remain unchanged: all required namespace-scoped checks must pass, and missing action support in any required namespace denies access. @@ -57,6 +63,7 @@ For multi-namespace resources, existing `AND` semantics remain unchanged: all re Validation is done through PDP and decisioning tests covering: - mode split (`NamespacedPolicy=false` vs `true`) for subject mapping inclusion, +- strict-mode subject mapping namespace scoping (unnamespaced SMs skipped), - namespace-aware action matching in rule evaluation paths, - multi-namespace resource behavior where one missing namespace action causes deny, - regression checks to confirm existing `AND` behavior is preserved. @@ -65,6 +72,7 @@ Validation is done through PDP and decisioning tests covering: - Thread `NamespacedPolicy` into PDP runtime configuration. - Filter subject mappings at PDP construction by mode (namespaced vs unnamespaced). +- Enforce subject mapping namespace consistency during create/update operations. - Centralize action matching in a namespace-aware helper used by all rule/action checks. - Derive required namespace per evaluated value/rule context. - Keep standard/custom action existence checks lazy at evaluation time. diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index a20e869ca6..0ec60dc6c3 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -81,9 +81,17 @@ func getResourceDecision( } for _, aav := range regResValue.GetActionAttributeValues() { aavAttrValueFQN := aav.GetAttributeValue().GetFqn() + matchesRequestIdentity := isRequestedActionMatch(action, "", aav.GetAction(), false) requiredNamespaceID := "" if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() + } else if namespacedPolicy && matchesRequestIdentity { + l.TraceContext( + ctx, + "strict namespaced-policy mode: unable to resolve namespace for RR action-attribute-value; denying access", + slog.String("attribute_value_fqn", aavAttrValueFQN), + ) + return failure, nil } // skip evaluating attribute rules on any action-attribute-values without the requested action diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 0a4f3cbd88..a6e17c2b10 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -1406,6 +1406,59 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_RequestActionIDPrecedence() s.False(decision.Entitled, "requested action id should take precedence over name match") } +func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesWhenAAVNamespaceCannotBeResolved() { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + + knownFQN := levelHighestFQN + unknownFQN := "https://unknown.example.com/attr/missing/value/x" + + s.accessibleAttrValues[knownFQN].Attribute.Namespace = namespaceA + s.accessibleRegisteredResourceValues[netRegResValFQN].ActionAttributeValues = []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Id: "rr-aav-known", + Action: &policy.Action{ + Name: actions.ActionNameRead, + Namespace: namespaceA, + }, + AttributeValue: &policy.Value{Fqn: knownFQN, Value: "highest"}, + }, + { + Id: "rr-aav-unknown", + Action: &policy.Action{ + Name: actions.ActionNameRead, + Namespace: namespaceA, + }, + AttributeValue: &policy.Value{Fqn: unknownFQN, Value: "x"}, + }, + } + + resource := &authz.Resource{ + Resource: &authz.Resource_RegisteredResourceValueFqn{RegisteredResourceValueFqn: netRegResValFQN}, + EphemeralId: "rr-aav-unresolvable-ns", + } + + entitlements := subjectmappingbuiltin.AttributeValueFQNsToActions{ + knownFQN: { + {Name: actions.ActionNameRead, Namespace: namespaceA}, + }, + } + + decision, err := getResourceDecision( + s.T().Context(), + s.logger, + s.accessibleAttrValues, + s.accessibleRegisteredResourceValues, + entitlements, + actionRead, + resource, + true, + ) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.Entitled, "strict mode should fail closed when any matching RR AAV namespace cannot be resolved") +} + func Test_isRequestedActionMatch(t *testing.T) { namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://ns-b.example.com"} diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index ef27f29aed..3862c6f8b0 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -395,7 +395,7 @@ func TestPopulateHigherValuesIfHierarchy(t *testing.T) { } valueRestricted := &policy.Value{ Fqn: exampleRestrictedFQN, - SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleSecretFQN, "restricted", []*policy.Action{actionRead}, ".test", []string{"somethingelse"}, nil)}, + SubjectMappings: []*policy.SubjectMapping{createSimpleSubjectMapping(exampleRestrictedFQN, "restricted", []*policy.Action{actionRead}, ".test", []string{"somethingelse"}, nil)}, } valueConf := &policy.Value{ Fqn: exampleConfidentialFQN, From d26824c0f366caad3b1729ba55b68a1c19bec14f Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 12:59:54 -0400 Subject: [PATCH 08/39] remove file --- ...paced-subject-mappings-decisioning-plan.md | 208 ------------------ 1 file changed, 208 deletions(-) delete mode 100644 docs/namespaced-subject-mappings-decisioning-plan.md diff --git a/docs/namespaced-subject-mappings-decisioning-plan.md b/docs/namespaced-subject-mappings-decisioning-plan.md deleted file mode 100644 index 9ad95b46bb..0000000000 --- a/docs/namespaced-subject-mappings-decisioning-plan.md +++ /dev/null @@ -1,208 +0,0 @@ -# Namespaced Subject Mappings: Decisioning Plan - -## Agreed Behavior - -- `NamespacedPolicy=false` => keep current legacy behavior (no namespace filtering at SM load time; existing name-based matching behavior remains). -- `NamespacedPolicy=true` => enforce namespaced subject mapping/action evaluation. -- For a single resource containing attribute values from multiple namespaces, keep existing `AND` behavior. -- If requested action is missing in any required namespace, fail closed. -- Standard action existence is checked lazily at evaluation time (no startup invariant). - -## Current Baseline (Verified) - -- Existing PDP decisioning for a single resource with multi-namespace attributes behaves as `AND` at data-rule level. - - Example coverage: `service/internal/access/v2/pdp_test.go:2344` and follow-up assertions. -- Current action checks are primarily name-based in evaluation paths. - - RR AAV filtering: `service/internal/access/v2/evaluate.go` (`getResourceDecision`) - - Rule checks: `allOfRule`, `anyOfRule`, `hierarchyRule` in `service/internal/access/v2/evaluate.go` - -## Design Decision - -Per-namespace action resolution is done during entitlement/rule evaluation (not request parsing). - -- Request action remains unnamespaced (`action.name` from request). -- Resolution context is the namespace of the value/SM/rule currently being evaluated. -- Match requires: - - name equality, and - - namespace equality (when `NamespacedPolicy=true`). - -This keeps decisioning context-local and avoids leaking cross-namespace action matches. - -## Request Action Semantics - -`GetDecisionRequest` currently carries `policy.Action`. We will support three request-action shapes with explicit precedence: - -1. `action.id` provided (most specific) - - Resolve exact action by ID. - - Treat resolved action namespace as authoritative. - - If ID is missing/invalid for the required evaluation context, fail closed. -2. `action.name` + `action.namespace` provided - - Resolve by name within provided namespace. - - If not found in required evaluation context, fail closed. -3. `action.name` only (least specific) - - Resolve contextually per evaluated namespace (value/rule namespace). - - In strict mode, this remains namespace-aware at evaluation time. - -### Precedence Rule - -- If `id` is present, use `id` semantics (ignore name-only fallback behavior). -- Else if `name+namespace` present, use scoped-name semantics. -- Else use name-only contextual semantics. - -## Implementation Plan (Detailed) - -### 1) Thread feature flag into PDP runtime - -Code touchpoints: - -- `service/internal/access/v2/pdp.go` - -Changes: - -- Add `namespacedPolicy bool` field on `PolicyDecisionPoint`. -- Extend `NewPolicyDecisionPoint(...)` to accept/set the flag. -- Thread flag from policy/access service construction into PDP instantiation sites. - -### 2) Filter SMs at PDP load time (strict mode only) - -Code touchpoint: - -- `service/internal/access/v2/pdp.go` inside `NewPolicyDecisionPoint` loop over `allSubjectMappings`. - -Changes: - -- In namespaced mode, skip SMs without namespace. -- In legacy mode, keep current behavior (no additional filtering introduced by this work). -- Keep validation + warning log on skipped/invalid SMs. - -Reason: - -- Enforces clean mode split once and prevents mixed-evaluation edge cases downstream. - -### 3) Add namespace-aware action match helper - -Code touchpoint: - -- `service/internal/access/v2/evaluate.go` - -Add helper (shape example): - -- `isRequestedActionMatch(requestedActionName string, requiredNamespaceID string, entitledAction *policy.Action, namespacedPolicy bool) bool` - -Behavior: - -- `namespacedPolicy=false`: preserve existing behavior (case-insensitive name match; no new namespace gating in this change). -- `namespacedPolicy=true`: case-insensitive name match and entitled action namespace must equal `requiredNamespaceID`. - -Request-shape integration: - -- `id` path: require exact ID match, then namespace consistency in strict mode. -- `name+namespace` path: require namespace-aware scoped name match. -- `name` path: namespace-aware contextual matching only when strict mode is enabled. - -### 4) Apply helper in all action comparison paths - -Code touchpoints: - -- `service/internal/access/v2/evaluate.go` - - RR AAV filtering in `getResourceDecision` - - `allOfRule` - - `anyOfRule` - - `hierarchyRule` - -Changes: - -- Replace name-only checks with helper-based namespace-aware checks. - -### 5) Carry namespace context into rule checks - -Problem: - -- Current rule functions use value FQNs and entitlements, but action checks don’t receive explicit namespace context per value. - -Plan: - -- Derive required namespace from `accessibleAttributeValues[valueFQN].Attribute.Namespace` (or value namespace if needed). -- Pass `requiredNamespaceID` into action match checks for each evaluated value FQN. - -### 6) Fail-closed semantics for missing action in required namespace - -Behavior: - -- If any required value/rule cannot find matching action in that namespace, rule fails. -- For multi-namespace resource under `AND`, one failure denies the resource decision. -- For explicit action identity (`id` or `name+namespace`), decisioning does not fall back to looser matching; it denies only when that explicit identity is unresolved or mismatched for the evaluated namespace context. - -Notes: - -- This naturally aligns with existing `allOfRule`/resource result aggregation model. - -### 7) Keep lazy standard-action behavior - -Behavior: - -- No startup seed validation required. -- If standard/custom action is missing in a required namespace at evaluation time, decision fails closed. - -### 8) Logging/audit improvements - -Add targeted debug/warn logs where mismatch/failure occurs: - -- requested action name -- required namespace -- candidate action namespace (if any) -- rule/value FQN context - -This will reduce debugging time when rollouts begin. - -## Test Plan (Detailed) - -### Unit/logic tests - -1. `NamespacedPolicy=false` - - legacy behavior unchanged (no strict namespace filtering introduced) - - regression checks for existing name-based matching behavior -2. `NamespacedPolicy=true` - - unnamespaced SM ignored - - namespaced SM evaluated -3. Namespace-aware action matching helper - - match name+namespace succeeds - - name match with wrong namespace fails - - legacy mode keeps existing name-based behavior -4. Request action shape precedence - - `id` beats `name` - - `name+namespace` is scoped - - `name` only is contextual - -### PDP behavior tests - -0. Legacy-mode regression (`NamespacedPolicy=false`) - - mixed namespaced + unnamespaced policy data continues to evaluate with current behavior (no strict namespace gating introduced) - -1. Multi-namespace attrs in one resource, action present in all namespaces => permit -2. Multi-namespace attrs in one resource, action missing in one namespace => deny -3. RR AAV path respects namespace-aware action match (not name-only) -4. Existing `AND` behavior remains unchanged outside namespace-aware action filtering -5. Action request-shape matrix (`id`, `name+namespace`, `name`) across both GetDecision APIs - -### Regression safety - -- Re-run existing decisioning and obligations PDP suites to ensure no unintended semantic shifts. - -## Rollout Plan - -1. Land code behind `NamespacedPolicy` flag. -2. Keep default behavior (`false`) in environments until migration complete. -3. Validate policy data readiness (SM/SCS/actions namespaced) before flip. -4. Flip to `true`; monitor deny/mismatch logs. -5. Remove compatibility branch once migration is complete and stable. - -## Open Questions - -- Exact expected error/log wording when action is missing in required namespace. -- Whether to expose namespace-mismatch reason in decision debug output/API diagnostics. - -## Non-Goals - -- Changing obligation aggregation semantics. -- Introducing mixed-mode fallback (`evaluate both namespaced + unnamespaced SM`) once flag logic is strict. From 3af77a22e2a6a2565f6129c0cf8122d1941d58b9 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 31 Mar 2026 13:13:44 -0400 Subject: [PATCH 09/39] direct entitlement handling --- ...namespaced-subject-mappings-decisioning.md | 7 ++ service/internal/access/v2/pdp.go | 22 +++++- service/internal/access/v2/pdp_test.go | 67 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md index 74f56e8a77..da710a0395 100644 --- a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md +++ b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md @@ -42,6 +42,13 @@ Feature-flag mode split: - `NamespacedPolicy=false`: preserve existing legacy behavior (no new namespace filtering semantics introduced by this change). - `NamespacedPolicy=true`: enforce namespaced subject mapping evaluation (unnamespaced SMs are ignored) and require action namespace equality for each evaluated namespace. +Direct entitlements in strict mode: + +- Direct entitlements are still modeled as action names per attribute-value FQN. +- During PDP evaluation, each direct-entitlement action is hydrated with the namespace of its attributed value context. +- This makes direct entitlements participate in the same namespace-aware action matching rules as subject-mapping-derived entitlements. +- Direct-entitlement actions are merged with subject-mapping actions per value FQN (not replacing them). + Subject mapping namespace enforcement (strict mode): - Subject mapping namespace must match the namespace of the referenced attribute value. diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index fbffcb7037..e3529f6ef7 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -229,10 +229,26 @@ func (p *PolicyDecisionPoint) GetDecision( for _, directEntitlement := range entityRepresentation.GetDirectEntitlements() { fqn := directEntitlement.GetAttributeValueFqn() 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 + // satisfy namespace-aware action matching during rule evaluation. + var actionNamespace *policy.Namespace + if attrAndValue, ok := decisionableAttributes[fqn]; ok { + actionNamespace = attrAndValue.GetAttribute().GetNamespace() + } else if attrAndValue, ok := p.allEntitleableAttributesByValueFQN[fqn]; ok { + // Fallback for direct entitlements that may not be present in the + // narrowed decisionable set for this specific request. + actionNamespace = attrAndValue.GetAttribute().GetNamespace() + } - actions := make([]*policy.Action, len(actionNames)) - for i, name := range actionNames { - actions[i] = &policy.Action{Name: name} + // Merge direct-entitlement actions with subject-mapping actions for the + // same value FQN instead of replacing them. + actions := entitledFQNsToActions[fqn] + for _, name := range actionNames { + actions = append(actions, &policy.Action{ + Name: name, + Namespace: actionNamespace, + }) } entitledFQNsToActions[fqn] = actions diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 8853274084..9b29c090c8 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -4095,6 +4095,73 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { }) } +func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements_StrictNamespacedPolicy() { + ctx := s.T().Context() + + namespace1 := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://demo.com"} + namespace2 := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://demo-two.com"} + + attr1 := &policy.Attribute{ + Fqn: "https://demo.com/attr/adhoc", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Namespace: namespace1, + Values: []*policy.Value{ + {Fqn: "https://demo.com/attr/adhoc/value/direct_entitlement_1", Value: "direct_entitlement_1"}, + }, + } + attr2 := &policy.Attribute{ + Fqn: "https://demo-two.com/attr/adhoc_2", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Namespace: namespace2, + Values: []*policy.Value{ + {Fqn: "https://demo-two.com/attr/adhoc_2/value/direct_entitlement_2", Value: "direct_entitlement_2"}, + }, + } + + attr1ValueFQN := attr1.GetValues()[0].GetFqn() + attr2ValueFQN := attr2.GetValues()[0].GetFqn() + + pdp, err := NewPolicyDecisionPoint( + ctx, + s.logger, + []*policy.Attribute{attr1, attr2}, + []*policy.SubjectMapping{}, + []*policy.RegisteredResource{}, + true, + true, + ) + s.Require().NoError(err) + + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + {AttributeValueFqn: attr1ValueFQN, Actions: []string{actions.ActionNameCreate}}, + {AttributeValueFqn: attr2ValueFQN, Actions: []string{actions.ActionNameCreate}}, + }, + } + + s.Run("permits when direct entitlement action resolves to required namespace", func() { + decision, _, decisionErr := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource(attr1ValueFQN, attr1ValueFQN), + createAttributeValueResource(attr2ValueFQN, attr2ValueFQN), + }) + s.Require().NoError(decisionErr) + s.Require().NotNil(decision) + s.True(decision.AllPermitted) + }) + + s.Run("denies when explicit request namespace mismatches", func() { + decision, _, decisionErr := pdp.GetDecision(ctx, entityRep, &policy.Action{ + Name: actions.ActionNameCreate, + Namespace: namespace1, + }, []*authz.Resource{ + createAttributeValueResource(attr2ValueFQN, attr2ValueFQN), + }) + s.Require().NoError(decisionErr) + s.Require().NotNil(decision) + s.False(decision.AllPermitted) + }) +} + // Helper functions for all tests // assertDecisionResult is a helper function to assert that a decision result for a given FQN matches the expected pass/fail state From fc917bf47fcdd2ed7f966cb7c826528b82b40bb4 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 10:56:46 -0400 Subject: [PATCH 10/39] linting, rename function --- service/internal/access/v2/evaluate.go | 18 +++++++++--------- service/internal/access/v2/pdp.go | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 0ec60dc6c3..b7c9cc2ce1 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -227,13 +227,13 @@ func evaluateDefinition( switch attrDefinition.GetRule() { case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: - entitlementFailures = allOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) + entitlementFailures = allOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: - entitlementFailures = anyOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) + entitlementFailures = anyOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: - entitlementFailures = hierarchyRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, requiredNamespaceID, namespacedPolicy) + entitlementFailures = hierarchyRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, requiredNamespaceID, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: return nil, fmt.Errorf("%w: %s, rule: %s", ErrMissingRequiredSpecifiedRule, attrDefinition.GetFqn(), attrDefinition.GetRule().String()) @@ -269,10 +269,10 @@ func allOfRule( action *policy.Action, resourceValueFQNs []string, ) []EntitlementFailure { - return allOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, "", false) + return allOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, "", false) } -func allOfRuleWithContext( +func allOfRuleScoped( _ context.Context, _ *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, @@ -321,10 +321,10 @@ func anyOfRule( action *policy.Action, resourceValueFQNs []string, ) []EntitlementFailure { - return anyOfRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, "", false) + return anyOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, "", false) } -func anyOfRuleWithContext( +func anyOfRuleScoped( _ context.Context, _ *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, @@ -385,10 +385,10 @@ func hierarchyRule( resourceValueFQNs []string, attrDefinition *policy.Attribute, ) []EntitlementFailure { - return hierarchyRuleWithContext(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, "", false) + return hierarchyRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, "", false) } -func hierarchyRuleWithContext( +func hierarchyRuleScoped( ctx context.Context, l *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index e3529f6ef7..ef3e595049 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -235,10 +235,10 @@ func (p *PolicyDecisionPoint) GetDecision( var actionNamespace *policy.Namespace if attrAndValue, ok := decisionableAttributes[fqn]; ok { actionNamespace = attrAndValue.GetAttribute().GetNamespace() - } else if attrAndValue, ok := p.allEntitleableAttributesByValueFQN[fqn]; ok { + } else if fallbackAttrAndValue, ok := p.allEntitleableAttributesByValueFQN[fqn]; ok { // Fallback for direct entitlements that may not be present in the // narrowed decisionable set for this specific request. - actionNamespace = attrAndValue.GetAttribute().GetNamespace() + actionNamespace = fallbackAttrAndValue.GetAttribute().GetNamespace() } // Merge direct-entitlement actions with subject-mapping actions for the From d2fed6607ec24ab6abb59af395ddeb25eec4e797 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 11:06:28 -0400 Subject: [PATCH 11/39] update ok --- service/internal/access/v2/pdp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index ef3e595049..994adaf7d3 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -235,7 +235,7 @@ func (p *PolicyDecisionPoint) GetDecision( var actionNamespace *policy.Namespace if attrAndValue, ok := decisionableAttributes[fqn]; ok { actionNamespace = attrAndValue.GetAttribute().GetNamespace() - } else if fallbackAttrAndValue, ok := p.allEntitleableAttributesByValueFQN[fqn]; ok { + } else if fallbackAttrAndValue, ok2 := p.allEntitleableAttributesByValueFQN[fqn]; ok2 { // Fallback for direct entitlements that may not be present in the // narrowed decisionable set for this specific request. actionNamespace = fallbackAttrAndValue.GetAttribute().GetNamespace() From 2d17070e3e65d029f6a91816f370f347f22de5ab Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 13:22:29 -0400 Subject: [PATCH 12/39] update features, change attr naming --- .../features/namespaced-decisioning.feature | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index 94057e8e95..f46711f9a5 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -5,88 +5,88 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Background: Given a user exists with username "alice" and email "alice@example.com" and the following attributes: - | name | value | - | clearance | ["TS"] | + | name | value | + | department | ["eng"] | And a local platform with platform template "cukes/resources/platform.namespaced_policy.template" and keycloak template "cukes/resources/keycloak_base.template" And I submit a request to create a namespace with name "ns-one.example.com" and reference id "ns1" And I submit a request to create a namespace with name "ns-two.example.com" and reference id "ns2" And I send a request to create an attribute with: | namespace_id | name | rule | values | - | ns1 | classification | hierarchy | topsecret | - | ns2 | classification | hierarchy | topsecret | - Then the response should be successful - And a condition group referenced as "cg_ts_clearance" with an "or" operator with conditions: - | selector_value | operator | values | - | .attributes.clearance[] | in | TS | - And a subject set referenced as "ss_ts_clearance" containing the condition groups "cg_ts_clearance" - And I send a request to create a subject condition set referenced as "scs_clearance_topsecret_ns1" in namespace "ns1" containing subject sets "ss_ts_clearance" - And I send a request to create a subject condition set referenced as "scs_clearance_topsecret_ns2" in namespace "ns2" containing subject sets "ss_ts_clearance" + | ns1 | department | any_of | eng | + | ns2 | department | any_of | eng | + Then the response should be successful + And a condition group referenced as "cg_eng_department" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a subject set referenced as "ss_eng_department" containing the condition groups "cg_eng_department" + And I send a request to create a subject condition set referenced as "scs_department_eng_ns1" in namespace "ns1" containing subject sets "ss_eng_department" + And I send a request to create a subject condition set referenced as "scs_department_eng_ns2" in namespace "ns2" containing subject sets "ss_eng_department" And there is a "user_name" subject entity with value "alice" and referenced as "alice" Scenario: Standard action name permits when entitled in resource namespace And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | read | | + | sm_ns1_read_eng | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | read | | Then the response should be successful - When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/department/value/eng" Then the response should be successful And I should get a "PERMIT" decision response Scenario: Standard action name denies when entitled only in different namespace And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_read_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | read | | + | sm_ns2_read_eng | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | read | | Then the response should be successful - When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/department/value/eng" Then the response should be successful And I should get a "DENY" decision response Scenario: Custom action name permits when entitled in resource namespace And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | | export | + | sm_ns1_export_eng | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | | export | Then the response should be successful - When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng" Then the response should be successful And I should get a "PERMIT" decision response Scenario: Custom action name denies when entitled only in different namespace And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_export_ts | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | | export | + | sm_ns2_export_eng | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | | export | Then the response should be successful - When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng" Then the response should be successful And I should get a "DENY" decision response Scenario: Standard action AND behavior across mixed namespaces And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_read_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | read | | + | sm_ns1_read_eng | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | read | | Then the response should be successful - When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/department/value/eng,https://ns-two.example.com/attr/department/value/eng" Then the response should be successful And I should get a "DENY" decision response And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_read_ts2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | read | | + | sm_ns2_read_eng_2 | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | read | | Then the response should be successful - When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/department/value/eng,https://ns-two.example.com/attr/department/value/eng" Then the response should be successful And I should get a "PERMIT" decision response Scenario: Custom action AND behavior across mixed namespaces And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns1_export_ts | ns1 | https://ns-one.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns1 | | export | + | sm_ns1_export_eng | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | | export | Then the response should be successful - When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng,https://ns-two.example.com/attr/department/value/eng" Then the response should be successful And I should get a "DENY" decision response And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_ns2_export_ts_2 | ns2 | https://ns-two.example.com/attr/classification/value/topsecret | scs_clearance_topsecret_ns2 | | export | + | sm_ns2_export_eng_2 | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | | export | Then the response should be successful - When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/classification/value/topsecret,https://ns-two.example.com/attr/classification/value/topsecret" + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng,https://ns-two.example.com/attr/department/value/eng" Then the response should be successful And I should get a "PERMIT" decision response From e03b0e54a1c5ac91ee98aad6ebe56289ed370b29 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 13:37:30 -0400 Subject: [PATCH 13/39] add comments, fix attr rule --- service/internal/access/v2/evaluate.go | 16 ++++++++++++++++ .../features/namespaced-decisioning.feature | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index b7c9cc2ce1..78cbd51ca8 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -81,11 +81,18 @@ func getResourceDecision( } for _, aav := range regResValue.GetActionAttributeValues() { aavAttrValueFQN := aav.GetAttributeValue().GetFqn() + // First, check whether the request action identity (id or name[/namespace]) + // could match this AAV action at all. This lightweight pre-check is used to + // decide whether strict mode must fail closed when namespace context is + // missing for this candidate AAV. matchesRequestIdentity := isRequestedActionMatch(action, "", aav.GetAction(), false) requiredNamespaceID := "" if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() } else if namespacedPolicy && matchesRequestIdentity { + // Strict namespaced policy: if this AAV is otherwise a candidate for the + // requested action but we cannot resolve the attribute-value namespace, + // deny rather than silently skipping evaluation. l.TraceContext( ctx, "strict namespaced-policy mode: unable to resolve namespace for RR action-attribute-value; denying access", @@ -269,6 +276,7 @@ func allOfRule( action *policy.Action, resourceValueFQNs []string, ) []EntitlementFailure { + // Legacy wrapper: evaluate without strict namespace constraints. return allOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, "", false) } @@ -321,6 +329,7 @@ func anyOfRule( action *policy.Action, resourceValueFQNs []string, ) []EntitlementFailure { + // Legacy wrapper: evaluate without strict namespace constraints. return anyOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, "", false) } @@ -385,6 +394,7 @@ func hierarchyRule( resourceValueFQNs []string, attrDefinition *policy.Attribute, ) []EntitlementFailure { + // Legacy wrapper: evaluate without strict namespace constraints. return hierarchyRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, "", false) } @@ -473,6 +483,10 @@ func isRequestedActionMatch(requestedAction *policy.Action, requiredNamespaceID return false } + // Action identity precedence: + // 1) request action id (if present) is authoritative, + // 2) otherwise name (case-insensitive), + // 3) optional request namespace (id or fqn) further narrows matches. if requestedAction.GetId() != "" { if requestedAction.GetId() != entitledAction.GetId() { return false @@ -500,6 +514,8 @@ func isRequestedActionMatch(requestedAction *policy.Action, requiredNamespaceID return true } + // Strict namespaced-policy mode requires a resolved target namespace from + // the resource/definition context and a namespaced entitled action. if requiredNamespaceID == "" { return false } diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index f46711f9a5..85c4917fee 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -12,8 +12,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) And I submit a request to create a namespace with name "ns-two.example.com" and reference id "ns2" And I send a request to create an attribute with: | namespace_id | name | rule | values | - | ns1 | department | any_of | eng | - | ns2 | department | any_of | eng | + | ns1 | department | anyOf | eng | + | ns2 | department | anyOf | eng | Then the response should be successful And a condition group referenced as "cg_eng_department" with an "or" operator with conditions: | selector_value | operator | values | From 7ef6a6e299b6ac75e3956bb5870088e5c168b76e Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 15:22:55 -0400 Subject: [PATCH 14/39] coderabbit suggestions --- service/internal/access/v2/evaluate.go | 58 ++++++++++++++++----- service/internal/access/v2/evaluate_test.go | 3 +- service/internal/access/v2/pdp.go | 4 +- service/internal/access/v2/pdp_test.go | 7 ++- 4 files changed, 57 insertions(+), 15 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 78cbd51ca8..c5c78cfe9d 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -85,7 +85,7 @@ func getResourceDecision( // could match this AAV action at all. This lightweight pre-check is used to // decide whether strict mode must fail closed when namespace context is // missing for this candidate AAV. - matchesRequestIdentity := isRequestedActionMatch(action, "", aav.GetAction(), false) + matchesRequestIdentity := isRequestedActionMatch(ctx, l, action, "", aav.GetAction(), false) requiredNamespaceID := "" if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() @@ -102,7 +102,7 @@ func getResourceDecision( } // skip evaluating attribute rules on any action-attribute-values without the requested action - if !isRequestedActionMatch(action, requiredNamespaceID, aav.GetAction(), namespacedPolicy) { + if !isRequestedActionMatch(ctx, l, action, requiredNamespaceID, aav.GetAction(), namespacedPolicy) { continue } @@ -281,8 +281,8 @@ func allOfRule( } func allOfRuleScoped( - _ context.Context, - _ *logger.Logger, + ctx context.Context, + l *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, @@ -299,7 +299,7 @@ func allOfRuleScoped( // Check if this FQN has the entitled action if entitledActions, ok := entitlements[valueFQN]; ok { for _, entitledAction := range entitledActions { - if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { hasEntitlement = true break } @@ -334,8 +334,8 @@ func anyOfRule( } func anyOfRuleScoped( - _ context.Context, - _ *logger.Logger, + ctx context.Context, + l *logger.Logger, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, @@ -358,7 +358,7 @@ func anyOfRuleScoped( entitledActions, ok := entitlements[valueFQN] if ok { for _, entitledAction := range entitledActions { - if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { foundEntitlementForThisFQN = true anyEntitlementFound = true break @@ -437,7 +437,7 @@ func hierarchyRuleScoped( if idx, exists := valueFQNToIndex[entitlementFQN]; exists && idx <= lowestValueFQNIndex { // Check if the required action is entitled for _, entitledAction := range entitledActions { - if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { l.DebugContext(ctx, "hierarchy rule satisfied", slog.Group("entitled_by_value", slog.String("FQN", entitlementFQN), @@ -460,7 +460,7 @@ func hierarchyRuleScoped( foundValue := false if entitledActions, ok := entitlements[valueFQN]; ok { for _, entitledAction := range entitledActions { - if isRequestedActionMatch(action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { foundValue = true break } @@ -478,7 +478,7 @@ func hierarchyRuleScoped( return entitlementFailures } -func isRequestedActionMatch(requestedAction *policy.Action, requiredNamespaceID string, entitledAction *policy.Action, namespacedPolicy bool) bool { +func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedAction *policy.Action, requiredNamespaceID string, entitledAction *policy.Action, namespacedPolicy bool) bool { if requestedAction == nil || entitledAction == nil { return false } @@ -489,23 +489,44 @@ func isRequestedActionMatch(requestedAction *policy.Action, requiredNamespaceID // 3) optional request namespace (id or fqn) further narrows matches. if requestedAction.GetId() != "" { if requestedAction.GetId() != entitledAction.GetId() { + l.TraceContext(ctx, "action match identity mismatch", + slog.String("requested_action_id", requestedAction.GetId()), + slog.String("candidate_action_id", entitledAction.GetId()), + ) return false } } else { if requestedAction.GetName() == "" || !strings.EqualFold(requestedAction.GetName(), entitledAction.GetName()) { + l.TraceContext(ctx, "action match identity mismatch", + slog.String("requested_action_name", requestedAction.GetName()), + slog.String("candidate_action_name", entitledAction.GetName()), + ) return false } } + // If the caller explicitly provides a request action namespace, always enforce + // that identity constraint regardless of namespacedPolicy mode. if requestNamespace := requestedAction.GetNamespace(); requestNamespace != nil && (requestNamespace.GetId() != "" || requestNamespace.GetFqn() != "") { entitledNamespace := entitledAction.GetNamespace() if entitledNamespace == nil { + l.TraceContext(ctx, "action match request namespace mismatch", + slog.String("requested_action_namespace_id", requestNamespace.GetId()), + ) return false } if requestNamespace.GetId() != "" && entitledNamespace.GetId() != requestNamespace.GetId() { + l.TraceContext(ctx, "action match request namespace mismatch", + slog.String("requested_action_namespace_id", requestNamespace.GetId()), + slog.String("candidate_namespace_id", entitledNamespace.GetId()), + ) return false } if requestNamespace.GetId() == "" && requestNamespace.GetFqn() != "" && !strings.EqualFold(entitledNamespace.GetFqn(), requestNamespace.GetFqn()) { + l.TraceContext(ctx, "action match request namespace mismatch", + slog.String("requested_action_namespace_fqn", requestNamespace.GetFqn()), + slog.String("candidate_namespace_fqn", entitledNamespace.GetFqn()), + ) return false } } @@ -517,13 +538,26 @@ func isRequestedActionMatch(requestedAction *policy.Action, requiredNamespaceID // Strict namespaced-policy mode requires a resolved target namespace from // the resource/definition context and a namespaced entitled action. if requiredNamespaceID == "" { + l.TraceContext(ctx, "action match strict namespace mismatch", + slog.String("required_namespace_id", requiredNamespaceID), + ) return false } entitledNamespace := entitledAction.GetNamespace() if entitledNamespace == nil || entitledNamespace.GetId() == "" { + l.TraceContext(ctx, "action match strict namespace mismatch", + slog.String("required_namespace_id", requiredNamespaceID), + ) + return false + } + if entitledNamespace.GetId() != requiredNamespaceID { + l.TraceContext(ctx, "action match strict namespace mismatch", + slog.String("required_namespace_id", requiredNamespaceID), + slog.String("candidate_namespace_id", entitledNamespace.GetId()), + ) return false } - return entitledNamespace.GetId() == requiredNamespaceID + return true } diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index a6e17c2b10..e4779f9958 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -1,6 +1,7 @@ package access import ( + "context" "strings" "testing" @@ -1547,7 +1548,7 @@ func Test_isRequestedActionMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - matched := isRequestedActionMatch(tt.requestedAction, tt.requiredNamespace, tt.entitledAction, tt.namespacedPolicy) + matched := isRequestedActionMatch(context.Background(), logger.CreateTestLogger(), tt.requestedAction, tt.requiredNamespace, tt.entitledAction, tt.namespacedPolicy) assert.Equal(t, tt.expectedActionMatch, matched) }) } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 994adaf7d3..b383d80733 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -132,7 +132,9 @@ func NewPolicyDecisionPoint( if ns == nil || (ns.GetId() == "" && ns.GetFqn() == "") { l.TraceContext(ctx, "unnamespaced subject mapping in strict namespaced-policy mode - skipping", - slog.Any("subject_mapping", sm), + slog.String("reason", "subject_mapping_namespace_missing"), + slog.String("subject_mapping_id", sm.GetId()), + slog.String("mapped_value_fqn", sm.GetAttributeValue().GetFqn()), ) continue } diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 9b29c090c8..1bb48654bd 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -2728,10 +2728,15 @@ func (s *PDPTestSuite) Test_GetDecision_StrictMode_RequestActionIdentityMatrix() permitted: true, }, { - name: "id match in wrong namespace denies", + name: "non-existent action id denies", action: &policy.Action{Id: idNamespaceBRead, Name: actions.ActionNameRead}, permitted: false, }, + { + name: "id match plus wrong request namespace denies", + action: &policy.Action{Id: idNamespaceARead, Name: actions.ActionNameRead, Namespace: namespaceB}, + permitted: false, + }, { name: "name plus matching namespace permits", action: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, From 6ee91c463a184ef919ba80079e45bcbb50daf119 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 16:50:31 -0400 Subject: [PATCH 15/39] registered resourced namespaced get decisions cukes --- tests-bdd/cukes/steps_registeredresources.go | 295 ++++++++++++++++++ .../features/namespaced-decisioning.feature | 94 ++++++ tests-bdd/platform_test.go | 1 + 3 files changed, 390 insertions(+) create mode 100644 tests-bdd/cukes/steps_registeredresources.go diff --git a/tests-bdd/cukes/steps_registeredresources.go b/tests-bdd/cukes/steps_registeredresources.go new file mode 100644 index 0000000000..5f7c8d59eb --- /dev/null +++ b/tests-bdd/cukes/steps_registeredresources.go @@ -0,0 +1,295 @@ +package cukes + +import ( + "context" + "fmt" + "strings" + + "github.com/cucumber/godog" + "github.com/opentdf/platform/lib/identifier" + authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" +) + +type RegisteredResourcesStepDefinitions struct{} + +func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredResourceWith(ctx context.Context, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + cellIndexMap := make(map[int]string) + for ri, r := range tbl.Rows { + if ri == 0 { + for ci, c := range r.Cells { + cellIndexMap[ci] = c.Value + } + continue + } + + req := ®isteredresources.CreateRegisteredResourceRequest{} + referenceID := "" + + for ci, c := range r.Cells { + v := strings.TrimSpace(c.Value) + switch cellIndexMap[ci] { + case "reference_id": + referenceID = v + case "name": + req.Name = v + case "namespace_id": + nsID, ok := scenarioContext.GetObject(v).(string) + if !ok { + return ctx, fmt.Errorf("namespace_id %s not found", v) + } + req.NamespaceId = nsID + case "namespace_fqn": + req.NamespaceFqn = v + } + } + + resp, err := scenarioContext.SDK.RegisteredResources.CreateRegisteredResource(ctx, req) + scenarioContext.SetError(err) + if err == nil && resp != nil { + if referenceID != "" { + scenarioContext.RecordObject(referenceID, resp.GetResource()) + } + scenarioContext.RecordObject(req.GetName(), resp.GetResource()) + } + } + + return ctx, nil +} + +func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredResourceValueWith(ctx context.Context, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + cellIndexMap := make(map[int]string) + for ri, r := range tbl.Rows { + if ri == 0 { + for ci, c := range r.Cells { + cellIndexMap[ci] = c.Value + } + continue + } + + req := ®isteredresources.CreateRegisteredResourceValueRequest{} + referenceID := "" + + for ci, c := range r.Cells { + v := strings.TrimSpace(c.Value) + switch cellIndexMap[ci] { + case "reference_id": + referenceID = v + case "resource_ref": + resource, ok := scenarioContext.GetObject(v).(*policy.RegisteredResource) + if !ok || resource == nil { + return ctx, fmt.Errorf("resource_ref %s not found", v) + } + req.ResourceId = resource.GetId() + case "value": + req.Value = v + case "action_attribute_values": + if v == "" { + continue + } + aavs, err := parseAAVs(v) + if err != nil { + return ctx, err + } + req.ActionAttributeValues = aavs + } + } + + resp, err := scenarioContext.SDK.RegisteredResources.CreateRegisteredResourceValue(ctx, req) + scenarioContext.SetError(err) + if err == nil && resp != nil { + if referenceID != "" { + scenarioContext.RecordObject(referenceID, resp.GetValue()) + } + scenarioContext.RecordObject(req.GetValue(), resp.GetValue()) + } + } + + return ctx, nil +} + +func parseAAVs(raw string) ([]*registeredresources.ActionAttributeValue, error) { + parts := strings.Split(raw, ",") + out := make([]*registeredresources.ActionAttributeValue, 0, len(parts)) + + for _, part := range parts { + pair := strings.Split(strings.TrimSpace(part), "|") + if len(pair) != 2 { + return nil, fmt.Errorf("invalid action_attribute_values entry %q, expected action|attribute_value_fqn", part) + } + + actionName := strings.TrimSpace(pair[0]) + attributeValueFQN := strings.TrimSpace(pair[1]) + if actionName == "" || attributeValueFQN == "" { + return nil, fmt.Errorf("invalid action_attribute_values entry %q, action and attribute value fqn are required", part) + } + + out = append(out, ®isteredresources.ActionAttributeValue{ + ActionIdentifier: ®isteredresources.ActionAttributeValue_ActionName{ + ActionName: strings.ToLower(actionName), + }, + AttributeValueIdentifier: ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: attributeValueFQN, + }, + }) + } + + return out, nil +} + +func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForEntityChainForActionOnRegisteredResourceValue(ctx context.Context, entityChainID string, action string, resourceValueRef string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + var entities []*entity.Entity + for _, entityID := range strings.Split(entityChainID, ",") { + ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) + if !ok { + return ctx, fmt.Errorf("entity %s not found or invalid type", entityID) + } + entities = append(entities, ent) + } + + entityChain := &entity.EntityChain{Entities: entities} + + resourceValueFQN := strings.TrimSpace(resourceValueRef) + if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { + if rrValue.GetResource() == nil { + return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) + } + namespaceName := "" + if rrValue.GetResource() != nil && rrValue.GetResource().GetNamespace() != nil { + namespaceName = rrValue.GetResource().GetNamespace().GetName() + } + resourceValueFQN = (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: namespaceName, + Name: rrValue.GetResource().GetName(), + Value: rrValue.GetValue(), + }).FQN() + } + + req := &authzV2.GetDecisionRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{EntityChain: entityChain}, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resource: &authzV2.Resource{ + EphemeralId: "resource1", + Resource: &authzV2.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: resourceValueFQN, + }, + }, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) + if err != nil { + scenarioContext.SetError(err) + return ctx, err + } + + scenarioContext.RecordObject(decisionResponse, resp) + return ctx, nil +} + +func (s *RegisteredResourcesStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnRegisteredResourceValues(ctx context.Context, entityChainID string, action string, resourceValueRefs string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + var entities []*entity.Entity + for _, entityID := range strings.Split(entityChainID, ",") { + ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) + if !ok { + return ctx, fmt.Errorf("entity %s not found or invalid type", entityID) + } + entities = append(entities, ent) + } + + entityChain := &entity.EntityChain{Entities: entities} + + resources := make([]*authzV2.Resource, 0) + resourceFQNMap := make(map[string]string) + for idx, resourceValueRef := range strings.Split(resourceValueRefs, ",") { + resourceValueRef = strings.TrimSpace(resourceValueRef) + resourceValueFQN := resourceValueRef + if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { + if rrValue.GetResource() == nil { + return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) + } + namespaceName := "" + if rrValue.GetResource().GetNamespace() != nil { + namespaceName = rrValue.GetResource().GetNamespace().GetName() + } + resourceValueFQN = (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: namespaceName, + Name: rrValue.GetResource().GetName(), + Value: rrValue.GetValue(), + }).FQN() + } + + ephemeralID := fmt.Sprintf("rrv-%d", idx) + resourceFQNMap[ephemeralID] = resourceValueFQN + resources = append(resources, &authzV2.Resource{ + EphemeralId: ephemeralID, + Resource: &authzV2.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: resourceValueFQN, + }, + }) + } + + req := &authzV2.GetDecisionMultiResourceRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{EntityChain: entityChain}, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resources: resources, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) + scenarioContext.SetError(err) + if err != nil { + return ctx, err + } + + scenarioContext.RecordObject(multiDecisionResponseKey, resp) + scenarioContext.RecordObject(decisionResponse, resp) + scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) + return ctx, nil +} + +func (s *RegisteredResourcesStepDefinitions) theMultiResourceDecisionShouldBe(ctx context.Context, expectedDecision string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + resp, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok || resp == nil { + return ctx, fmt.Errorf("multi-decision response not found or invalid") + } + + allPermitted := resp.GetAllPermitted() + if allPermitted == nil { + return ctx, fmt.Errorf("multi-decision missing all_permitted flag") + } + + expected := strings.EqualFold(expectedDecision, "PERMIT") + if allPermitted.GetValue() != expected { + return ctx, fmt.Errorf("unexpected multi-decision result: got %v expected %v", allPermitted.GetValue(), expected) + } + + return ctx, nil +} + +func RegisterRegisteredResourcesStepDefinitions(ctx *godog.ScenarioContext) { + stepDefinitions := &RegisteredResourcesStepDefinitions{} + ctx.Step(`^I send a request to create a registered resource with:$`, stepDefinitions.iSendARequestToCreateARegisteredResourceWith) + ctx.Step(`^I send a request to create a registered resource value with:$`, stepDefinitions.iSendARequestToCreateARegisteredResourceValueWith) + ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on registered resource value "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnRegisteredResourceValue) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on registered resource values "([^"]*)"$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnRegisteredResourceValues) + ctx.Step(`^the multi-resource decision should be "([^"]*)"$`, stepDefinitions.theMultiResourceDecisionShouldBe) +} diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index 85c4917fee..a3f0165b52 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -90,3 +90,97 @@ Feature: Namespaced Policy Decisioning (name-only action requests) When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng,https://ns-two.example.com/attr/department/value/eng" Then the response should be successful And I should get a "PERMIT" decision response + + Scenario: Registered resource value permits when action is entitled in same namespace + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_rr_ns1_read | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | read | | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_ns1 | ns1 | app-config | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_ns1 | rr_ns1 | prod-config | read|https://ns-one.example.com/attr/department/value/eng | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_ns1" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value denies when action is only entitled in different namespace + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_rr_ns2_read | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | read | | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_ns1 | ns1 | app-config | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_ns1 | rr_ns1 | prod-config | read|https://ns-one.example.com/attr/department/value/eng | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_ns1" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value permits for custom action in same namespace + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_rr_ns1_export | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | | export | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_ns1 | ns1 | app-config | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_ns1 | rr_ns1 | prod-config | custom_action_export|https://ns-one.example.com/attr/department/value/eng | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on registered resource value "rrv_ns1" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value denies for custom action entitled only in different namespace + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_rr_ns2_export | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | | export | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_ns1 | ns1 | app-config | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_ns1 | rr_ns1 | prod-config | custom_action_export|https://ns-one.example.com/attr/department/value/eng | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on registered resource value "rrv_ns1" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resources mixed-namespace decision is fail-closed (AND) + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_rr_mix_ns1_read | ns1 | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | read | | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_mix_ns1 | ns1 | app-config-a | + | rr_mix_ns2 | ns2 | app-config-b | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_mix_ns1 | rr_mix_ns1 | prod-config-a | read|https://ns-one.example.com/attr/department/value/eng | + | rrv_mix_ns2 | rr_mix_ns2 | prod-config-b | read|https://ns-two.example.com/attr/department/value/eng | + Then the response should be successful + When I send a multi-resource decision request for entity chain "alice" for "read" action on registered resource values "rrv_mix_ns1,rrv_mix_ns2" + Then the response should be successful + And the multi-resource decision should be "DENY" + And I send a request to create a subject mapping with: + | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_rr_mix_ns2_read | ns2 | https://ns-two.example.com/attr/department/value/eng | scs_department_eng_ns2 | read | | + Then the response should be successful + When I send a multi-resource decision request for entity chain "alice" for "read" action on registered resource values "rrv_mix_ns1,rrv_mix_ns2" + Then the response should be successful + And the multi-resource decision should be "PERMIT" diff --git a/tests-bdd/platform_test.go b/tests-bdd/platform_test.go index 67c0dd51f8..039afb000b 100644 --- a/tests-bdd/platform_test.go +++ b/tests-bdd/platform_test.go @@ -91,6 +91,7 @@ func runTests() int { cukes.RegisterSmokeStepDefinitions(ctx, platformCukesContext) cukes.RegisterAuthorizationStepDefinitions(ctx) cukes.RegisterSubjectMappingsStepsDefinitions(ctx) + cukes.RegisterRegisteredResourcesStepDefinitions(ctx) cukes.RegisterObligationsStepDefinitions(ctx, platformCukesContext) platformCukesContext.InitializeScenario(ctx) }, From 6a859db626dd1c69421ebf36bb3cfea176f5168c Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 1 Apr 2026 16:59:26 -0400 Subject: [PATCH 16/39] linting, fix feature file aav --- tests-bdd/cukes/steps_registeredresources.go | 24 +++++++++++++------ .../features/namespaced-decisioning.feature | 12 +++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests-bdd/cukes/steps_registeredresources.go b/tests-bdd/cukes/steps_registeredresources.go index 5f7c8d59eb..41f92d14dc 100644 --- a/tests-bdd/cukes/steps_registeredresources.go +++ b/tests-bdd/cukes/steps_registeredresources.go @@ -2,6 +2,7 @@ package cukes import ( "context" + "errors" "fmt" "strings" @@ -15,6 +16,11 @@ import ( type RegisteredResourcesStepDefinitions struct{} +const ( + referenceIDColumn = "reference_id" + aavPairParts = 2 +) + func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredResourceWith(ctx context.Context, tbl *godog.Table) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() @@ -34,7 +40,7 @@ func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredRes for ci, c := range r.Cells { v := strings.TrimSpace(c.Value) switch cellIndexMap[ci] { - case "reference_id": + case referenceIDColumn: referenceID = v case "name": req.Name = v @@ -81,7 +87,7 @@ func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredRes for ci, c := range r.Cells { v := strings.TrimSpace(c.Value) switch cellIndexMap[ci] { - case "reference_id": + case referenceIDColumn: referenceID = v case "resource_ref": resource, ok := scenarioContext.GetObject(v).(*policy.RegisteredResource) @@ -121,9 +127,13 @@ func parseAAVs(raw string) ([]*registeredresources.ActionAttributeValue, error) out := make([]*registeredresources.ActionAttributeValue, 0, len(parts)) for _, part := range parts { - pair := strings.Split(strings.TrimSpace(part), "|") - if len(pair) != 2 { - return nil, fmt.Errorf("invalid action_attribute_values entry %q, expected action|attribute_value_fqn", part) + entry := strings.TrimSpace(part) + pair := strings.SplitN(entry, "=>", aavPairParts) + if len(pair) != aavPairParts { + pair = strings.SplitN(entry, "|", aavPairParts) + } + if len(pair) != aavPairParts { + return nil, fmt.Errorf("invalid action_attribute_values entry %q, expected action=>attribute_value_fqn", part) } actionName := strings.TrimSpace(pair[0]) @@ -269,12 +279,12 @@ func (s *RegisteredResourcesStepDefinitions) theMultiResourceDecisionShouldBe(ct scenarioContext := GetPlatformScenarioContext(ctx) resp, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) if !ok || resp == nil { - return ctx, fmt.Errorf("multi-decision response not found or invalid") + return ctx, errors.New("multi-decision response not found or invalid") } allPermitted := resp.GetAllPermitted() if allPermitted == nil { - return ctx, fmt.Errorf("multi-decision missing all_permitted flag") + return ctx, errors.New("multi-decision missing all_permitted flag") } expected := strings.EqualFold(expectedDecision, "PERMIT") diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index a3f0165b52..e314e6e7c1 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -102,7 +102,7 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I send a request to create a registered resource value with: | reference_id | resource_ref | value | action_attribute_values | - | rrv_ns1 | rr_ns1 | prod-config | read|https://ns-one.example.com/attr/department/value/eng | + | rrv_ns1 | rr_ns1 | prod-config | read=>https://ns-one.example.com/attr/department/value/eng | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_ns1" Then the response should be successful @@ -119,7 +119,7 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I send a request to create a registered resource value with: | reference_id | resource_ref | value | action_attribute_values | - | rrv_ns1 | rr_ns1 | prod-config | read|https://ns-one.example.com/attr/department/value/eng | + | rrv_ns1 | rr_ns1 | prod-config | read=>https://ns-one.example.com/attr/department/value/eng | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_ns1" Then the response should be successful @@ -136,7 +136,7 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I send a request to create a registered resource value with: | reference_id | resource_ref | value | action_attribute_values | - | rrv_ns1 | rr_ns1 | prod-config | custom_action_export|https://ns-one.example.com/attr/department/value/eng | + | rrv_ns1 | rr_ns1 | prod-config | custom_action_export=>https://ns-one.example.com/attr/department/value/eng | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on registered resource value "rrv_ns1" Then the response should be successful @@ -153,7 +153,7 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I send a request to create a registered resource value with: | reference_id | resource_ref | value | action_attribute_values | - | rrv_ns1 | rr_ns1 | prod-config | custom_action_export|https://ns-one.example.com/attr/department/value/eng | + | rrv_ns1 | rr_ns1 | prod-config | custom_action_export=>https://ns-one.example.com/attr/department/value/eng | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on registered resource value "rrv_ns1" Then the response should be successful @@ -171,8 +171,8 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I send a request to create a registered resource value with: | reference_id | resource_ref | value | action_attribute_values | - | rrv_mix_ns1 | rr_mix_ns1 | prod-config-a | read|https://ns-one.example.com/attr/department/value/eng | - | rrv_mix_ns2 | rr_mix_ns2 | prod-config-b | read|https://ns-two.example.com/attr/department/value/eng | + | rrv_mix_ns1 | rr_mix_ns1 | prod-config-a | read=>https://ns-one.example.com/attr/department/value/eng | + | rrv_mix_ns2 | rr_mix_ns2 | prod-config-b | read=>https://ns-two.example.com/attr/department/value/eng | Then the response should be successful When I send a multi-resource decision request for entity chain "alice" for "read" action on registered resource values "rrv_mix_ns1,rrv_mix_ns2" Then the response should be successful From eb7dd8935973ca44af8ea51e4d5ef11a47300a3d Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Thu, 2 Apr 2026 10:46:38 -0400 Subject: [PATCH 17/39] handle namespaced rr fqn indexing --- service/internal/access/v2/pdp.go | 32 +++++++++++++- service/internal/access/v2/pdp_test.go | 61 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index b383d80733..dd54043532 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -171,11 +171,20 @@ func NewPolicyDecisionPoint( return nil, fmt.Errorf("invalid registered resource value: %w", err) } + namespaceName := namespaceNameFromPolicyNamespace(rr.GetNamespace()) + fullyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: namespaceName, + Name: rrName, + Value: v.GetValue(), + } + allRegisteredResourceValuesByFQN[fullyQualifiedValue.FQN()] = v + + legacyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ Name: rrName, Value: v.GetValue(), } - allRegisteredResourceValuesByFQN[fullyQualifiedValue.FQN()] = v + allRegisteredResourceValuesByFQN[legacyQualifiedValue.FQN()] = v } } @@ -190,6 +199,27 @@ func NewPolicyDecisionPoint( return pdp, nil } +func namespaceNameFromPolicyNamespace(ns *policy.Namespace) string { + if ns == nil { + return "" + } + + if ns.GetName() != "" { + return ns.GetName() + } + + if ns.GetFqn() == "" { + return "" + } + + parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](ns.GetFqn()) + if err != nil { + return "" + } + + return parsed.Namespace +} + // GetDecision evaluates the action on the resources for the entity and returns a decision along with entitlements. func (p *PolicyDecisionPoint) GetDecision( ctx context.Context, diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 1bb48654bd..9b46a0b599 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -907,6 +907,67 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint_AllowsAttributeDefinitionsWith s.Require().NotContains(pdp.allEntitleableAttributesByValueFQN, emptyAttrFQN) } +func (s *PDPTestSuite) TestNewPolicyDecisionPoint_IndexesRegisteredResourceValuesByNamespacedAndLegacyFQN() { + tests := []struct { + name string + namespaceName string + namespaceFQN string + expectedNS string + }{ + { + name: "uses namespace name when present", + namespaceName: "ns-one.example.com", + namespaceFQN: "https://ns-one.example.com", + expectedNS: "ns-one.example.com", + }, + { + name: "falls back to namespace fqn when name absent", + namespaceFQN: "https://ns-two.example.com", + expectedNS: "ns-two.example.com", + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + regResVal := &policy.RegisteredResourceValue{Value: "prod-config"} + regRes := &policy.RegisteredResource{ + Name: "app-config", + Namespace: &policy.Namespace{ + Name: tc.namespaceName, + Fqn: tc.namespaceFQN, + }, + Values: []*policy.RegisteredResourceValue{regResVal}, + } + + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{}, + []*policy.SubjectMapping{}, + []*policy.RegisteredResource{regRes}, + allowDirectEntitlements, + true, + ) + s.Require().NoError(err) + + namespacedFQN := (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: tc.expectedNS, + Name: regRes.GetName(), + Value: regResVal.GetValue(), + }).FQN() + legacyFQN := (&identifier.FullyQualifiedRegisteredResourceValue{ + Name: regRes.GetName(), + Value: regResVal.GetValue(), + }).FQN() + + s.Require().Contains(pdp.allRegisteredResourceValuesByFQN, namespacedFQN) + s.Require().Contains(pdp.allRegisteredResourceValuesByFQN, legacyFQN) + s.Same(regResVal, pdp.allRegisteredResourceValuesByFQN[namespacedFQN]) + s.Same(regResVal, pdp.allRegisteredResourceValuesByFQN[legacyFQN]) + }) + } +} + // Test_GetDecision_MultipleResources tests the GetDecision method with some generalized scenarios for multiple resources func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { f := s.fixtures From 1fa8804547d1e13a15ecbecd0b3b4eeb981f9084 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Thu, 2 Apr 2026 11:11:48 -0400 Subject: [PATCH 18/39] update go mod --- tests-bdd/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests-bdd/go.mod b/tests-bdd/go.mod index 31c91c1b72..c61462f213 100644 --- a/tests-bdd/go.mod +++ b/tests-bdd/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.5 github.com/opentdf/platform/lib/fixtures v0.3.0 + github.com/opentdf/platform/lib/identifier v0.0.2 github.com/opentdf/platform/protocol/go v0.15.0 github.com/opentdf/platform/sdk v0.5.0 github.com/opentdf/platform/service v0.7.2 @@ -164,7 +165,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentdf/platform/lib/flattening v0.1.3 // indirect - github.com/opentdf/platform/lib/identifier v0.0.2 // indirect github.com/opentdf/platform/lib/ocrypto v0.9.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect From 15b110f8b4bc5524580297703ccae58367b1562f Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Thu, 2 Apr 2026 18:13:13 -0400 Subject: [PATCH 19/39] trim space --- tests-bdd/cukes/steps_registeredresources.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests-bdd/cukes/steps_registeredresources.go b/tests-bdd/cukes/steps_registeredresources.go index 41f92d14dc..758c215b42 100644 --- a/tests-bdd/cukes/steps_registeredresources.go +++ b/tests-bdd/cukes/steps_registeredresources.go @@ -170,7 +170,7 @@ func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForEntityChain entityChain := &entity.EntityChain{Entities: entities} resourceValueFQN := strings.TrimSpace(resourceValueRef) - if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { + if rrValue, ok := scenarioContext.GetObject(resourceValueFQN).(*policy.RegisteredResourceValue); ok && rrValue != nil { if rrValue.GetResource() == nil { return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) } From 4743bc1a976f9fae9cfa67fc830f2ec0d9b6bd8c Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 7 Apr 2026 15:08:06 -0400 Subject: [PATCH 20/39] rename flag, address comment --- ...namespaced-subject-mappings-decisioning.md | 22 +++++++++---------- service/authorization/v2/authorization.go | 8 +++---- service/authorization/v2/config.go | 6 ++--- service/internal/access/v2/evaluate.go | 4 +++- .../platform.namespaced_policy.template | 2 +- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md index da710a0395..3391a2faf1 100644 --- a/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md +++ b/adr/decisions/2026-03-30-namespaced-subject-mappings-decisioning.md @@ -14,7 +14,7 @@ driver: '@elizabethhealy' Policy objects are moving toward strict namespace ownership, but access decisioning still treats actions as unscoped names in several evaluation paths. Subject mappings are also transitioning from legacy unnamespaced records to namespace-owned records. This creates ambiguity when a request action name exists in multiple namespaces and when a single resource includes attributes from multiple namespaces. -We need a decisioning model that is namespace-correct, fail-closed, and compatible with staged rollout using the `NamespacedPolicy` feature flag. +We need a decisioning model that is namespace-correct, fail-closed, and compatible with staged rollout using the `EnforceNamespacedEntitlements` feature flag. ## Decision Drivers @@ -27,20 +27,20 @@ We need a decisioning model that is namespace-correct, fail-closed, and compatib Chosen option: **Resolve request action identity within each evaluation namespace context**. -The normative request-action contract supports three request shapes. During evaluation, matching is always applied per namespace context (derived from the rule/value being evaluated), not globally. +For `GetDecisionRequest`/`GetDecisionMultiResourceRequest`, request validation still requires `action.name` (current proto contract). During evaluation, matching is always applied per namespace context (derived from the rule/value being evaluated), not globally. -Request-action identity precedence is explicit: +Request-action matching precedence is explicit (given the request action object): -1. `action.id` (exact identity) -2. `action.name + action.namespace` (scoped identity) +1. `action.id` (exact identity, when present) +2. `action.name + action.namespace` (scoped identity, when namespace is present) 3. `action.name` only (contextual identity) When identity is explicit (`id` or `name+namespace`), decisioning does not fall back to looser name-only matching. It fails closed only if that explicit identity is unresolved or mismatched for the evaluated namespace context. Feature-flag mode split: -- `NamespacedPolicy=false`: preserve existing legacy behavior (no new namespace filtering semantics introduced by this change). -- `NamespacedPolicy=true`: enforce namespaced subject mapping evaluation (unnamespaced SMs are ignored) and require action namespace equality for each evaluated namespace. +- `EnforceNamespacedEntitlements=false`: preserve existing legacy behavior (no new namespace filtering semantics introduced by this change). +- `EnforceNamespacedEntitlements=true`: enforce namespaced subject mapping evaluation (unnamespaced SMs are ignored) and require action namespace equality for each evaluated namespace. Direct entitlements in strict mode: @@ -69,7 +69,7 @@ For multi-namespace resources, existing `AND` semantics remain unchanged: all re Validation is done through PDP and decisioning tests covering: -- mode split (`NamespacedPolicy=false` vs `true`) for subject mapping inclusion, +- mode split (`EnforceNamespacedEntitlements=false` vs `true`) for subject mapping inclusion, - strict-mode subject mapping namespace scoping (unnamespaced SMs skipped), - namespace-aware action matching in rule evaluation paths, - multi-namespace resource behavior where one missing namespace action causes deny, @@ -77,7 +77,7 @@ Validation is done through PDP and decisioning tests covering: ## Implementation Notes -- Thread `NamespacedPolicy` into PDP runtime configuration. +- Thread `EnforceNamespacedEntitlements` into PDP runtime configuration. - Filter subject mappings at PDP construction by mode (namespaced vs unnamespaced). - Enforce subject mapping namespace consistency during create/update operations. - Centralize action matching in a namespace-aware helper used by all rule/action checks. @@ -87,8 +87,8 @@ Validation is done through PDP and decisioning tests covering: ## Rollout -1. Land logic behind `NamespacedPolicy`. +1. Land logic behind `EnforceNamespacedEntitlements`. 2. Keep default mode as legacy (`false`) until policy data migration is complete. 3. Validate namespaced policy data readiness. -4. Flip `NamespacedPolicy=true` and monitor mismatch/deny behavior. +4. Flip `EnforceNamespacedEntitlements=true` and monitor mismatch/deny behavior. 5. Remove legacy branch once namespaced mode is stable. diff --git a/service/authorization/v2/authorization.go b/service/authorization/v2/authorization.go index dc9795cbc0..72bb237f4e 100644 --- a/service/authorization/v2/authorization.go +++ b/service/authorization/v2/authorization.go @@ -149,7 +149,7 @@ func (as *Service) GetEntitlements(ctx context.Context, req *connect.Request[aut withComprehensiveHierarchy := req.Msg.GetWithComprehensiveHierarchy() // When authorization service can consume cached policy, switch to the other PDP (process based on policy passed in) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetEntitlements, ErrFailedToInitPDP, err)) } @@ -176,7 +176,7 @@ func (as *Service) GetDecision(ctx context.Context, req *connect.Request[authzV2 propagator := otel.GetTextMapPropagator() ctx = propagator.Extract(ctx, propagation.HeaderCarrier(req.Header())) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } @@ -226,7 +226,7 @@ func (as *Service) GetDecisionMultiResource(ctx context.Context, req *connect.Re propagator := otel.GetTextMapPropagator() ctx = propagator.Extract(ctx, propagation.HeaderCarrier(req.Header())) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } @@ -279,7 +279,7 @@ func (as *Service) GetDecisionBulk(ctx context.Context, req *connect.Request[aut propagator := otel.GetTextMapPropagator() ctx = propagator.Extract(ctx, propagation.HeaderCarrier(req.Header())) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.NamespacedPolicy) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } diff --git a/service/authorization/v2/config.go b/service/authorization/v2/config.go index 81b001d04f..44e16a8c34 100644 --- a/service/authorization/v2/config.go +++ b/service/authorization/v2/config.go @@ -21,8 +21,8 @@ type Config struct { // enable entity direct entitlements that do not require subject mappings AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"` - // enforce strict namespaced policy evaluation behavior in access decisioning - NamespacedPolicy bool `mapstructure:"namespaced_policy" json:"namespaced_policy" default:"false"` + // enforce strict namespaced entitlement evaluation behavior in access decisioning + EnforceNamespacedEntitlements bool `mapstructure:"enforce_namespaced_entitlements" json:"enforce_namespaced_entitlements" default:"false"` } // Validate tests for a sensible configuration @@ -60,6 +60,6 @@ func (c *Config) LogValue() slog.Value { ), ), slog.Bool("allow_direct_entitlements", c.AllowDirectEntitlements), - slog.Bool("namespaced_policy", c.NamespacedPolicy), + slog.Bool("enforce_namespaced_entitlements", c.EnforceNamespacedEntitlements), ) } diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index c5c78cfe9d..f88798bc1c 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -483,10 +483,12 @@ func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedActi return false } - // Action identity precedence: + // Action identity precedence for matching: // 1) request action id (if present) is authoritative, // 2) otherwise name (case-insensitive), // 3) optional request namespace (id or fqn) further narrows matches. + // Note: API validation still requires request action name today; this logic + // defines matcher behavior when additional identity fields are present. if requestedAction.GetId() != "" { if requestedAction.GetId() != entitledAction.GetId() { l.TraceContext(ctx, "action match identity mismatch", diff --git a/tests-bdd/cukes/resources/platform.namespaced_policy.template b/tests-bdd/cukes/resources/platform.namespaced_policy.template index 8a5edc0515..8100846eba 100644 --- a/tests-bdd/cukes/resources/platform.namespaced_policy.template +++ b/tests-bdd/cukes/resources/platform.namespaced_policy.template @@ -29,7 +29,7 @@ db: schema: otdf services: authorization: - namespaced_policy: true + enforce_namespaced_entitlements: true kas: keyring: - kid: e1 From ca09e5198d17cedb3b45571ad916fc17a3c68725 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Tue, 7 Apr 2026 16:04:07 -0400 Subject: [PATCH 21/39] suggestions --- service/internal/access/v2/evaluate.go | 4 ++- service/internal/access/v2/pdp.go | 28 ++++++++++++++----- .../subject_mapping_builtin_actions.go | 18 +++++++----- .../subject_mapping_builtin_actions_test.go | 21 ++++++++------ 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index f88798bc1c..5dfed74f95 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -81,11 +81,13 @@ func getResourceDecision( } for _, aav := range regResValue.GetActionAttributeValues() { aavAttrValueFQN := aav.GetAttributeValue().GetFqn() + precheckNamespaceID := "" + precheckNamespacedPolicy := false // First, check whether the request action identity (id or name[/namespace]) // could match this AAV action at all. This lightweight pre-check is used to // decide whether strict mode must fail closed when namespace context is // missing for this candidate AAV. - matchesRequestIdentity := isRequestedActionMatch(ctx, l, action, "", aav.GetAction(), false) + matchesRequestIdentity := isRequestedActionMatch(ctx, l, action, precheckNamespaceID, aav.GetAction(), precheckNamespacedPolicy) requiredNamespaceID := "" if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index dd54043532..75d01a64cc 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -7,6 +7,7 @@ import ( "log/slog" "slices" "strconv" + "strings" "github.com/opentdf/platform/lib/identifier" authz "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -247,7 +248,7 @@ func (p *PolicyDecisionPoint) GetDecision( l.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable_attribute_values_count", len(decisionableAttributes))) // Resolve them to their entitled FQNs and the actions available on each - entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation) + entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation, l.Logger) if err != nil { return nil, nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } @@ -344,20 +345,33 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( 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())) + attrVal := aav.GetAttributeValue() + attrValFQN := attrVal.GetFqn() + + requiredNamespaceID := "" + if attrAndValue, ok := decisionableAttributes[attrValFQN]; ok { + requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() + } + + if !isRequestedActionMatch(ctx, l, action, requiredNamespaceID, aavAction, p.namespacedPolicy) { + l.DebugContext(ctx, "skipping action not matching Decision Request action", + slog.String("action_name", aavAction.GetName()), + slog.String("attribute_value_fqn", attrValFQN), + slog.String("required_namespace_id", requiredNamespaceID), + ) 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() + if a.GetId() != "" && aavAction.GetId() != "" { + return a.GetId() == aavAction.GetId() + } + return strings.EqualFold(a.GetName(), aavAction.GetName()) }) { actionsList = append(actionsList, aavAction) } @@ -422,7 +436,7 @@ func (p *PolicyDecisionPoint) GetEntitlements( } // Resolve them to their entitled FQNs and the actions available on each - entityIDsToFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(entitleableAttributes, entityRepresentations) + entityIDsToFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(entitleableAttributes, entityRepresentations, l.Logger) if err != nil { return nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go index b0cb55025f..36c305c6d9 100644 --- a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go +++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go @@ -20,10 +20,11 @@ type EntityIDsToEntitlements map[string]AttributeValueFQNsToActions func EvaluateSubjectMappingMultipleEntitiesWithActions( attributeMappings map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue, entityRepresentations []*entityresolutionV2.EntityRepresentation, + l *slog.Logger, ) (EntityIDsToEntitlements, error) { results := make(map[string]AttributeValueFQNsToActions, len(entityRepresentations)) for _, er := range entityRepresentations { - entitlements, err := EvaluateSubjectMappingsWithActions(attributeMappings, er) + entitlements, err := EvaluateSubjectMappingsWithActions(attributeMappings, er, l) if err != nil { return nil, err } @@ -37,6 +38,7 @@ func EvaluateSubjectMappingMultipleEntitiesWithActions( func EvaluateSubjectMappingsWithActions( resolveableAttributes map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue, entityRepresentation *entityresolutionV2.EntityRepresentation, + l *slog.Logger, ) (AttributeValueFQNsToActions, error) { jsonEntities := entityRepresentation.GetAdditionalProps() entitlementsSet := make(map[string][]*policy.Action) @@ -83,12 +85,14 @@ func EvaluateSubjectMappingsWithActions( key := strings.ToLower(action.GetName()) if existing, ok := m[key]; ok { if actionsConflict(existing, action) { - slog.Warn( - "subject mapping action name collision with conflicting identity; using deterministic preference", - slog.String("action_name", key), - slog.Any("existing_action", existing), - slog.Any("candidate_action", action), - ) + if l != nil { + l.Warn( + "subject mapping action name collision with conflicting identity; using deterministic preference", + slog.String("action_name", key), + slog.Any("existing_action", existing), + slog.Any("candidate_action", action), + ) + } } m[key] = preferAction(existing, action) continue diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go index 2ed38c1e9e..de31db4c9c 100644 --- a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go +++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go @@ -143,7 +143,7 @@ func TestEvaluateSubjectMappingMultipleEntitiesWithActions_SingleEntity(t *testi ), } - result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(attributeMappings, []*entityresolutionV2.EntityRepresentation{engineeringEntity}) + result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(attributeMappings, []*entityresolutionV2.EntityRepresentation{engineeringEntity}, nil) require.NoError(t, err) assert.Len(t, result, 1) @@ -187,6 +187,7 @@ func TestEvaluateSubjectMappingMultipleEntitiesWithActions_MultipleEntities(t *t result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions( attributeMappings, []*entityresolutionV2.EntityRepresentation{engineeringEntity, salesEntity}, + nil, ) // Validate results @@ -235,6 +236,7 @@ func TestEvaluateSubjectMappingMultipleEntitiesWithActions_NoMatchingEntities(t result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions( attributeMappings, []*entityresolutionV2.EntityRepresentation{marketingEntity}, + nil, ) // Validate results @@ -269,6 +271,7 @@ func TestEvaluateSubjectMappingMultipleEntitiesWithActions_MultipleAttributes(t result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions( attributeMappings, []*entityresolutionV2.EntityRepresentation{engineeringEntity}, + nil, ) // Validate results @@ -356,7 +359,7 @@ func TestEvaluateSubjectMappingsWithActions_OneGoodResolution(t *testing.T) { ), } // Execute function - entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, entity) + entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, entity, nil) require.NoError(t, err) assert.Len(t, entitlements, 1) @@ -422,7 +425,7 @@ func TestEvaluateSubjectMappingsWithActions_MultipleMatchingSubjectMappings(t *t } // Execute function - entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, multiMatchEntity) + entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, multiMatchEntity, nil) // Validate results require.NoError(t, err) @@ -469,7 +472,7 @@ func TestEvaluateSubjectMappingsWithActions_DeduplicatesConflictingActionNamesDe classConfFQN: createAttributeMapping(classConfFQN, sm), } - entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, entity) + entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, entity, nil) require.NoError(t, err) actionsList, exists := entitlements[classConfFQN] @@ -501,7 +504,7 @@ func TestEvaluateSubjectMappingsWithActions_NoMatchingSubjectMappings(t *testing } // Execute function - entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, marketingEntity) + entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, marketingEntity, nil) // Validate results require.NoError(t, err) @@ -587,7 +590,7 @@ func TestEvaluateSubjectMappingsWithActions_ComplexCondition_MultipleConditionGr } // Test senior engineer - seniorEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, seniorEngEntity) + seniorEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, seniorEngEntity, nil) require.NoError(t, err) assert.Empty(t, seniorEntitlements) seniorActions, exists := seniorEntitlements[classRestrictedFQN] @@ -595,7 +598,7 @@ func TestEvaluateSubjectMappingsWithActions_ComplexCondition_MultipleConditionGr assert.Empty(t, seniorActions) // Test principal engineer with admin - adminEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, principalEngWithAdmin) + adminEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, principalEngWithAdmin, nil) require.NoError(t, err) assert.Len(t, adminEntitlements, 1) seniorWithAdminActions, exists := adminEntitlements[classRestrictedFQN] @@ -609,7 +612,7 @@ func TestEvaluateSubjectMappingsWithActions_ComplexCondition_MultipleConditionGr assert.Contains(t, actionNames, actions.ActionNameDelete) // Test senior engineer with admin in a different index - adminEntitlementsBadIndex, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, seniorEngWithAdminEntityInBadIndex) + adminEntitlementsBadIndex, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, seniorEngWithAdminEntityInBadIndex, nil) require.NoError(t, err) assert.Empty(t, adminEntitlementsBadIndex) adminActionsBadIndex, exists := adminEntitlementsBadIndex[classRestrictedFQN] @@ -617,7 +620,7 @@ func TestEvaluateSubjectMappingsWithActions_ComplexCondition_MultipleConditionGr assert.Empty(t, adminActionsBadIndex) // Test non-engineering admin - nonEngAdminEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, nonEngAdminEntity) + nonEngAdminEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, nonEngAdminEntity, nil) require.NoError(t, err) assert.Empty(t, nonEngAdminEntitlements) nonEngAdminActions, exists := nonEngAdminEntitlements[classRestrictedFQN] From 49eec3f97ab64d7f909ce08d5997f6916a211552 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 8 Apr 2026 12:54:13 -0400 Subject: [PATCH 22/39] lint --- service/internal/access/v2/pdp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 75d01a64cc..ded23a551b 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -349,7 +349,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( attrValFQN := attrVal.GetFqn() requiredNamespaceID := "" - if attrAndValue, ok := decisionableAttributes[attrValFQN]; ok { + if attrAndValue, ok2 := decisionableAttributes[attrValFQN]; ok2 { requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() } From 5e8b6bc96a5786886251e51f5989da81c679754b Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Thu, 9 Apr 2026 10:21:35 -0400 Subject: [PATCH 23/39] remove id/name mismatch logs --- service/internal/access/v2/evaluate.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 5dfed74f95..a90863d497 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -493,18 +493,10 @@ func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedActi // defines matcher behavior when additional identity fields are present. if requestedAction.GetId() != "" { if requestedAction.GetId() != entitledAction.GetId() { - l.TraceContext(ctx, "action match identity mismatch", - slog.String("requested_action_id", requestedAction.GetId()), - slog.String("candidate_action_id", entitledAction.GetId()), - ) return false } } else { if requestedAction.GetName() == "" || !strings.EqualFold(requestedAction.GetName(), entitledAction.GetName()) { - l.TraceContext(ctx, "action match identity mismatch", - slog.String("requested_action_name", requestedAction.GetName()), - slog.String("candidate_action_name", entitledAction.GetName()), - ) return false } } From 5078bd2e722fa9a496f53c386a735d65a26d411f Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Thu, 9 Apr 2026 10:22:56 -0400 Subject: [PATCH 24/39] remove trace log for action id/name mismatch --- service/internal/access/v2/evaluate.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 5dfed74f95..a90863d497 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -493,18 +493,10 @@ func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedActi // defines matcher behavior when additional identity fields are present. if requestedAction.GetId() != "" { if requestedAction.GetId() != entitledAction.GetId() { - l.TraceContext(ctx, "action match identity mismatch", - slog.String("requested_action_id", requestedAction.GetId()), - slog.String("candidate_action_id", entitledAction.GetId()), - ) return false } } else { if requestedAction.GetName() == "" || !strings.EqualFold(requestedAction.GetName(), entitledAction.GetName()) { - l.TraceContext(ctx, "action match identity mismatch", - slog.String("requested_action_name", requestedAction.GetName()), - slog.String("candidate_action_name", entitledAction.GetName()), - ) return false } } From f1bfac9d9febd314fd845602761c7ad8ead0685d Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 14:14:25 -0400 Subject: [PATCH 25/39] RR entities and resource should be namespaced if flag on --- service/internal/access/v2/evaluate.go | 52 +++++---- service/internal/access/v2/evaluate_test.go | 105 ++++++++++++++--- service/internal/access/v2/pdp.go | 39 ++++++- service/internal/access/v2/pdp_test.go | 123 +++++++++++--------- 4 files changed, 226 insertions(+), 93 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index a90863d497..b04437953c 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + "github.com/opentdf/platform/lib/identifier" authz "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/policy" attrs "github.com/opentdf/platform/protocol/go/policy/attributes" @@ -39,6 +40,7 @@ func getResourceDecision( var ( resourceID = resource.GetEphemeralId() registeredResourceValueFQN string + requiredNamespace string resourceAttributeValues *authz.Resource_AttributeValues failure = &ResourceDecision{ Entitled: false, @@ -62,6 +64,18 @@ func getResourceDecision( l = l.With("registered_resource_value_fqn", registeredResourceValueFQN) failure.ResourceName = registeredResourceValueFQN + // If namespaced policies are enabled, enforce that the registered resource value FQN is namespaced and extract the required namespace for later checks + if namespacedPolicy { + parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) + if err != nil { + return nil, fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) + } + if parsed.Namespace == "" { + return nil, fmt.Errorf("registered resource value FQN must be namespaced in strict mode [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) + } + requiredNamespace = parsed.Namespace + } + regResValue, found := accessibleRegisteredResourceValues[registeredResourceValueFQN] if !found { l.WarnContext( @@ -81,26 +95,24 @@ func getResourceDecision( } for _, aav := range regResValue.GetActionAttributeValues() { aavAttrValueFQN := aav.GetAttributeValue().GetFqn() - precheckNamespaceID := "" - precheckNamespacedPolicy := false - // First, check whether the request action identity (id or name[/namespace]) - // could match this AAV action at all. This lightweight pre-check is used to - // decide whether strict mode must fail closed when namespace context is - // missing for this candidate AAV. - matchesRequestIdentity := isRequestedActionMatch(ctx, l, action, precheckNamespaceID, aav.GetAction(), precheckNamespacedPolicy) - requiredNamespaceID := "" - if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { - requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() - } else if namespacedPolicy && matchesRequestIdentity { - // Strict namespaced policy: if this AAV is otherwise a candidate for the - // requested action but we cannot resolve the attribute-value namespace, - // deny rather than silently skipping evaluation. - l.TraceContext( - ctx, - "strict namespaced-policy mode: unable to resolve namespace for RR action-attribute-value; denying access", - slog.String("attribute_value_fqn", aavAttrValueFQN), - ) - return failure, nil + var requiredNamespaceID string + // If namespaced policies are enabled, enforce that the attribute value FQN is in the same namespace as the registered resource value and extract the namespace ID for later checks. + // This is a fail safe, as RR and attr NS match should be enforced on creation and update of registered resources + // This ensures that only attribute values from the correct namespace are considered in the evaluation. + if namespacedPolicy { + parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](aavAttrValueFQN) + if err != nil { + return nil, fmt.Errorf("invalid attribute value FQN [%s]: %w", aavAttrValueFQN, ErrInvalidResource) + } + if parsed.Namespace != requiredNamespace { + return nil, fmt.Errorf("attribute value FQN [%s] namespace [%s] does not match RR namespace [%s]: %w", aavAttrValueFQN, parsed.Namespace, requiredNamespace, ErrInvalidResource) + } + // Since we dont have the ns id on the RR value, pull it from the attr val + if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { + requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() + } else { + return nil, fmt.Errorf("AAV attribute value FQN [%s] not found in accessible attributes: %w", aavAttrValueFQN, ErrFQNNotFound) + } } // skip evaluating attribute rules on any action-attribute-values without the requested action diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index e4779f9958..1a5a8f25d9 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/opentdf/platform/lib/identifier" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -19,7 +20,7 @@ import ( // Constants for namespaces and attribute FQNs var ( // Base namespaces - baseNamespace = "https://namespace.com" + baseNamespace = "namespace.com" levelFQN = createAttrFQN(baseNamespace, "level") departmentFQN = createAttrFQN(baseNamespace, "department") projectFQN = createAttrFQN(baseNamespace, "project") @@ -43,8 +44,9 @@ var ( projectFantasicFourFQN = createAttrValueFQN(baseNamespace, "project", "fantasticfour") // Registered resource values - netRegResValFQN = createRegisteredResourceValueFQN("network", "external") - platRegResValFQN = createRegisteredResourceValueFQN("platform", "internal") + netRegResValFQN = createRegisteredResourceValueFQN("", "network", "external") + platRegResValFQN = createRegisteredResourceValueFQN("", "platform", "internal") + ns1NetRegResValFQN = createRegisteredResourceValueFQN("ns1", "network", "external") ) var ( @@ -207,6 +209,33 @@ func (s *EvaluateTestSuite) SetupTest() { }, }, }, + ns1NetRegResValFQN: { + Resource: &policy.RegisteredResource{ + Namespace: &policy.Namespace{ + Id: "ns1", + }, + }, + Id: "ns1-network-registered-res-id", + Value: "external", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Id: "ns1-network-action-attr-val-1", + Action: actionRead, + AttributeValue: &policy.Value{ + Fqn: levelHighestFQN, + Value: "highest", + }, + }, + { + Id: "ns1-network-action-attr-val-2", + Action: actionCreate, + AttributeValue: &policy.Value{ + Fqn: levelMidFQN, + Value: "mid", + }, + }, + }, + }, } } @@ -1017,12 +1046,13 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { // Test cases for getResourceDecision func (s *EvaluateTestSuite) TestGetResourceDecision() { - nonExistentRegResValueFQN := createRegisteredResourceValueFQN("nonexistent", "value") + nonExistentRegResValueFQN := createRegisteredResourceValueFQN("", "nonexistent", "value") tests := []struct { name string resource *authz.Resource entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + namespaced bool expectError bool expectPass bool }{ @@ -1039,6 +1069,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ levelMidFQN: []*policy.Action{actionRead}, }, + namespaced: false, expectError: false, expectPass: true, }, @@ -1053,6 +1084,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ levelHighestFQN: []*policy.Action{actionRead}, }, + namespaced: false, expectError: false, expectPass: true, }, @@ -1068,6 +1100,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { projectAvengersFQN: []*policy.Action{actionRead}, projectJusticeLeagueFQN: []*policy.Action{actionRead}, }, + namespaced: false, expectError: false, expectPass: true, }, @@ -1083,6 +1116,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { // Missing projectJusticeLeagueFQN projectAvengersFQN: []*policy.Action{actionRead}, }, + namespaced: false, expectError: false, expectPass: false, // Missing entitlement for projectJusticeLeagueFQN }, @@ -1098,9 +1132,22 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { // Wrong action levelHighestFQN: []*policy.Action{actionCreate}, }, + namespaced: false, expectError: false, expectPass: false, }, + { + name: "registered resource value unnamespaced in strict mode", + resource: &authz.Resource{ + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: createRegisteredResourceValueFQN("", "network", "external"), + }, + EphemeralId: "test-reg-res-id-unnamespaced-strict", + }, + entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + namespaced: true, + expectError: true, + }, { name: "nonexistent registered resource value - should DENY", resource: &authz.Resource{ @@ -1110,6 +1157,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { EphemeralId: "test-reg-res-id-5", }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + namespaced: false, expectError: false, expectPass: false, }, @@ -1126,6 +1174,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { EphemeralId: "test-attr-missing-fqns", }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + namespaced: false, expectError: false, expectPass: false, }, @@ -1142,6 +1191,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { EphemeralId: "test-attr-missing-fqns", }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + namespaced: false, expectError: false, expectPass: false, }, @@ -1158,6 +1208,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { EphemeralId: "test-attr-missing-fqns", }, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + namespaced: false, expectError: false, expectPass: false, }, @@ -1177,6 +1228,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ levelMidFQN: []*policy.Action{actionRead}, }, + namespaced: false, expectError: false, expectPass: false, }, @@ -1184,6 +1236,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { name: "invalid nil resource", resource: nil, entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{}, + namespaced: false, expectError: true, }, { @@ -1197,6 +1250,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { entitlements: subjectmappingbuiltin.AttributeValueFQNsToActions{ levelHighestFQN: []*policy.Action{actionRead}, }, + namespaced: false, expectError: false, expectPass: true, }, @@ -1212,7 +1266,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { tc.entitlements, s.action, tc.resource, - false, + tc.namespaced, ) if tc.expectError { @@ -1333,9 +1387,19 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesOnActionNa func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_RegisteredResourceFiltersAAVByNamespace() { namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} namespaceB := &policy.Namespace{Id: "22222222-2222-2222-2222-222222222222", Fqn: "https://ns-b.example.com"} + namespacedRegResFQN := (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: "namespace.com", + Name: "network", + Value: "external", + }).FQN() s.accessibleAttrValues[levelHighestFQN].Attribute.Namespace = namespaceA - s.accessibleRegisteredResourceValues[netRegResValFQN].ActionAttributeValues = []*policy.RegisteredResourceValue_ActionAttributeValue{ + regResValue := s.accessibleRegisteredResourceValues[netRegResValFQN] + regResValue.Resource = &policy.RegisteredResource{ + Name: "network", + Namespace: namespaceA, + } + regResValue.ActionAttributeValues = []*policy.RegisteredResourceValue_ActionAttributeValue{ { Id: "network-action-attr-val-wrong-ns", Action: &policy.Action{ @@ -1345,9 +1409,10 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_RegisteredResour AttributeValue: &policy.Value{Fqn: levelHighestFQN, Value: "highest"}, }, } + s.accessibleRegisteredResourceValues[namespacedRegResFQN] = regResValue resource := &authz.Resource{ - Resource: &authz.Resource_RegisteredResourceValueFqn{RegisteredResourceValueFqn: netRegResValFQN}, + Resource: &authz.Resource_RegisteredResourceValueFqn{RegisteredResourceValueFqn: namespacedRegResFQN}, EphemeralId: "rr-ns-mismatch-resource", } @@ -1374,7 +1439,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_RegisteredResour } func (s *EvaluateTestSuite) Test_getResourceDecision_RequestActionIDPrecedence() { - namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://namespace.com"} s.accessibleAttrValues[projectAvengersFQN].Attribute.Namespace = namespaceA @@ -1407,14 +1472,24 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_RequestActionIDPrecedence() s.False(decision.Entitled, "requested action id should take precedence over name match") } -func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesWhenAAVNamespaceCannotBeResolved() { - namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://ns-a.example.com"} +func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_ErrorsOnAAVNamespaceMismatch() { + namespaceA := &policy.Namespace{Id: "11111111-1111-1111-1111-111111111111", Fqn: "https://namespace.com"} + namespacedRegResFQN := (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: "namespace.com", + Name: "network", + Value: "external", + }).FQN() knownFQN := levelHighestFQN unknownFQN := "https://unknown.example.com/attr/missing/value/x" s.accessibleAttrValues[knownFQN].Attribute.Namespace = namespaceA - s.accessibleRegisteredResourceValues[netRegResValFQN].ActionAttributeValues = []*policy.RegisteredResourceValue_ActionAttributeValue{ + regResValue := s.accessibleRegisteredResourceValues[netRegResValFQN] + regResValue.Resource = &policy.RegisteredResource{ + Name: "network", + Namespace: namespaceA, + } + regResValue.ActionAttributeValues = []*policy.RegisteredResourceValue_ActionAttributeValue{ { Id: "rr-aav-known", Action: &policy.Action{ @@ -1432,9 +1507,10 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesWhenAAVNam AttributeValue: &policy.Value{Fqn: unknownFQN, Value: "x"}, }, } + s.accessibleRegisteredResourceValues[namespacedRegResFQN] = regResValue resource := &authz.Resource{ - Resource: &authz.Resource_RegisteredResourceValueFqn{RegisteredResourceValueFqn: netRegResValFQN}, + Resource: &authz.Resource_RegisteredResourceValueFqn{RegisteredResourceValueFqn: namespacedRegResFQN}, EphemeralId: "rr-aav-unresolvable-ns", } @@ -1455,9 +1531,8 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesWhenAAVNam true, ) - s.Require().NoError(err) - s.Require().NotNil(decision) - s.False(decision.Entitled, "strict mode should fail closed when any matching RR AAV namespace cannot be resolved") + s.Require().Error(err) + s.Nil(decision) } func Test_isRequestedActionMatch(t *testing.T) { diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index ded23a551b..8a95d99963 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -181,11 +181,13 @@ func NewPolicyDecisionPoint( } allRegisteredResourceValuesByFQN[fullyQualifiedValue.FQN()] = v - legacyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ - Name: rrName, - Value: v.GetValue(), + if !namespacedPolicy { + legacyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ + Name: rrName, + Value: v.GetValue(), + } + allRegisteredResourceValuesByFQN[legacyQualifiedValue.FQN()] = v } - allRegisteredResourceValuesByFQN[legacyQualifiedValue.FQN()] = v } } @@ -237,7 +239,15 @@ func (p *PolicyDecisionPoint) GetDecision( } // 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, p.allAttributesByDefinitionFQN /* action, */, resources, p.allowDirectEntitlements) + decisionableAttributes, err := getResourceDecisionableAttributes( + ctx, + l, + p.allRegisteredResourceValuesByFQN, + p.allEntitleableAttributesByValueFQN, + p.allAttributesByDefinitionFQN, /* action, */ + resources, + p.allowDirectEntitlements, + ) if err != nil { if !errors.Is(err, ErrFQNNotFound) { return nil, nil, fmt.Errorf("error getting decisionable attributes: %w", err) @@ -329,6 +339,15 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( if err := validateGetDecisionRegisteredResource(entityRegisteredResourceValueFQN, action, resources); err != nil { return nil, nil, err } + if p.namespacedPolicy { + parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](entityRegisteredResourceValueFQN) + if err != nil { + return nil, nil, fmt.Errorf("invalid registered resource value FQN [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) + } + if parsed.Namespace == "" { + return nil, nil, fmt.Errorf("registered resource value FQN must be namespaced in strict mode [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) + } + } entityRegisteredResourceValue, ok := p.allRegisteredResourceValuesByFQN[entityRegisteredResourceValueFQN] if !ok { @@ -336,7 +355,15 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( } // 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, p.allAttributesByDefinitionFQN /*action, */, resources, p.allowDirectEntitlements) + decisionableAttributes, err := getResourceDecisionableAttributes( + ctx, + l, + p.allRegisteredResourceValuesByFQN, + p.allEntitleableAttributesByValueFQN, + p.allAttributesByDefinitionFQN, /*action, */ + resources, + p.allowDirectEntitlements, + ) if err != nil { return nil, nil, fmt.Errorf("error getting decisionable attributes: %w", err) } diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 9b46a0b599..f3d98c3003 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -46,10 +46,11 @@ func createAttrValueFQN(namespace, name, value string) string { } // Helper function to create registered resource value FQNs -func createRegisteredResourceValueFQN(name, value string) string { +func createRegisteredResourceValueFQN(ns, name, value string) string { resourceValue := &identifier.FullyQualifiedRegisteredResourceValue{ - Name: name, - Value: value, + Namespace: ns, + Name: name, + Value: value, } return resourceValue.FQN() } @@ -92,20 +93,20 @@ var ( testPlatformHybridFQN = createAttrValueFQN(testSecondaryNamespace, "platform", "hybrid") // Registered resource value FQNs - testNetworkPrivateFQN = createRegisteredResourceValueFQN("network", "private") - testNetworkPublicFQN = createRegisteredResourceValueFQN("network", "public") + testNetworkPrivateFQN = createRegisteredResourceValueFQN("", "network", "private") + testNetworkPublicFQN = createRegisteredResourceValueFQN("", "network", "public") ) // registered resource value FQNs using identifier package var ( // Classification values - testClassSecretRegResFQN = createRegisteredResourceValueFQN("classification", "secret") - testClassConfidentialRegResFQN = createRegisteredResourceValueFQN("classification", "confidential") + testClassSecretRegResFQN = createRegisteredResourceValueFQN("", "classification", "secret") + testClassConfidentialRegResFQN = createRegisteredResourceValueFQN("", "classification", "confidential") // Department values - testDeptEngineeringRegResFQN = createRegisteredResourceValueFQN("department", "engineering") - testDeptFinanceRegResFQN = createRegisteredResourceValueFQN("department", "finance") - testProjectAlphaRegResFQN = createRegisteredResourceValueFQN("project", "alpha") + testDeptEngineeringRegResFQN = createRegisteredResourceValueFQN("", "department", "engineering") + testDeptFinanceRegResFQN = createRegisteredResourceValueFQN("", "department", "finance") + testProjectAlphaRegResFQN = createRegisteredResourceValueFQN("", "project", "alpha") ) // Registered resource value FQNs using identifier package @@ -808,12 +809,12 @@ func (s *PDPTestSuite) SetupTest() { s.fixtures.regResValMultiActionMultiAttrVal = regResValMultiActionMultiAttrVal s.fixtures.regResValComprehensiveHierarchyActionAttrVal = regResValComprehensiveHierarchyActionAttrVal - regResValNoActionAttrValFQN = createRegisteredResourceValueFQN(regRes.GetName(), regResValNoActionAttrVal.GetValue()) - regResValSingleActionAttrValFQN = createRegisteredResourceValueFQN(regRes.GetName(), regResValSingleActionAttrVal.GetValue()) - regResValDuplicateActionAttrValFQN = createRegisteredResourceValueFQN(regRes.GetName(), regResValDuplicateActionAttrVal.GetValue()) - regResValMultiActionSingleAttrValFQN = createRegisteredResourceValueFQN(regRes.GetName(), regResValMultiActionSingleAttrVal.GetValue()) - regResValMultiActionMultiAttrValFQN = createRegisteredResourceValueFQN(regRes.GetName(), regResValMultiActionMultiAttrVal.GetValue()) - regResValComprehensiveHierarchyActionAttrValFQN = createRegisteredResourceValueFQN(regRes.GetName(), regResValComprehensiveHierarchyActionAttrVal.GetValue()) + regResValNoActionAttrValFQN = createRegisteredResourceValueFQN("", regRes.GetName(), regResValNoActionAttrVal.GetValue()) + regResValSingleActionAttrValFQN = createRegisteredResourceValueFQN("", regRes.GetName(), regResValSingleActionAttrVal.GetValue()) + regResValDuplicateActionAttrValFQN = createRegisteredResourceValueFQN("", regRes.GetName(), regResValDuplicateActionAttrVal.GetValue()) + regResValMultiActionSingleAttrValFQN = createRegisteredResourceValueFQN("", regRes.GetName(), regResValMultiActionSingleAttrVal.GetValue()) + regResValMultiActionMultiAttrValFQN = createRegisteredResourceValueFQN("", regRes.GetName(), regResValMultiActionMultiAttrVal.GetValue()) + regResValComprehensiveHierarchyActionAttrValFQN = createRegisteredResourceValueFQN("", regRes.GetName(), regResValComprehensiveHierarchyActionAttrVal.GetValue()) } // TestPDPSuite runs the test suite @@ -909,21 +910,35 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint_AllowsAttributeDefinitionsWith func (s *PDPTestSuite) TestNewPolicyDecisionPoint_IndexesRegisteredResourceValuesByNamespacedAndLegacyFQN() { tests := []struct { - name string - namespaceName string - namespaceFQN string - expectedNS string + name string + namespaceName string + namespaceFQN string + expectedNS string + namespacedPolicy bool + expectLegacyFQN bool }{ { - name: "uses namespace name when present", - namespaceName: "ns-one.example.com", - namespaceFQN: "https://ns-one.example.com", - expectedNS: "ns-one.example.com", + name: "uses namespace name when present (legacy indexed when not strict)", + namespaceName: "ns-one.example.com", + namespaceFQN: "https://ns-one.example.com", + expectedNS: "ns-one.example.com", + namespacedPolicy: false, + expectLegacyFQN: true, + }, + { + name: "falls back to namespace fqn when name absent (legacy indexed when not strict)", + namespaceFQN: "https://ns-two.example.com", + expectedNS: "ns-two.example.com", + namespacedPolicy: false, + expectLegacyFQN: true, }, { - name: "falls back to namespace fqn when name absent", - namespaceFQN: "https://ns-two.example.com", - expectedNS: "ns-two.example.com", + name: "strict mode skips legacy FQN", + namespaceName: "ns-one.example.com", + namespaceFQN: "https://ns-one.example.com", + expectedNS: "ns-one.example.com", + namespacedPolicy: true, + expectLegacyFQN: false, }, } @@ -946,7 +961,7 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint_IndexesRegisteredResourceValue []*policy.SubjectMapping{}, []*policy.RegisteredResource{regRes}, allowDirectEntitlements, - true, + tc.namespacedPolicy, ) s.Require().NoError(err) @@ -961,9 +976,13 @@ func (s *PDPTestSuite) TestNewPolicyDecisionPoint_IndexesRegisteredResourceValue }).FQN() s.Require().Contains(pdp.allRegisteredResourceValuesByFQN, namespacedFQN) - s.Require().Contains(pdp.allRegisteredResourceValuesByFQN, legacyFQN) s.Same(regResVal, pdp.allRegisteredResourceValuesByFQN[namespacedFQN]) - s.Same(regResVal, pdp.allRegisteredResourceValuesByFQN[legacyFQN]) + if tc.expectLegacyFQN { + s.Require().Contains(pdp.allRegisteredResourceValuesByFQN, legacyFQN) + s.Same(regResVal, pdp.allRegisteredResourceValuesByFQN[legacyFQN]) + } else { + s.Require().NotContains(pdp.allRegisteredResourceValuesByFQN, legacyFQN) + } }) } } @@ -1169,8 +1188,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "rnd", }) - rndDeptRegResFQN := createRegisteredResourceValueFQN(f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) - topsecretClassRegResFQN := createRegisteredResourceValueFQN(f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) + rndDeptRegResFQN := createRegisteredResourceValueFQN("", f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) + topsecretClassRegResFQN := createRegisteredResourceValueFQN("", f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) resources := []*authz.Resource{ { @@ -1216,8 +1235,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "rnd", }) - rndDeptRegResFQN := createRegisteredResourceValueFQN(f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) - topsecretClassRegResFQN := createRegisteredResourceValueFQN(f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) + rndDeptRegResFQN := createRegisteredResourceValueFQN("", f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) + topsecretClassRegResFQN := createRegisteredResourceValueFQN("", f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) // Upper case both registered resource value FQNs for assurance FQNs will be case-normalized resources := []*authz.Resource{ @@ -1264,8 +1283,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "finance", // Not rnd }) - rndDeptRegResFQN := createRegisteredResourceValueFQN(f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) - topsecretClassRegResFQN := createRegisteredResourceValueFQN(f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) + rndDeptRegResFQN := createRegisteredResourceValueFQN("", f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) + topsecretClassRegResFQN := createRegisteredResourceValueFQN("", f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) resources := []*authz.Resource{ { @@ -1311,8 +1330,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "rnd", // subject mapping permits read/update }) - rndDeptRegResFQN := createRegisteredResourceValueFQN(f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) - topsecretClassRegResFQN := createRegisteredResourceValueFQN(f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) + rndDeptRegResFQN := createRegisteredResourceValueFQN("", f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) + topsecretClassRegResFQN := createRegisteredResourceValueFQN("", f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) resources := []*authz.Resource{ { @@ -1357,8 +1376,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "rnd", // subject mapping permits read/update }) - rndDeptRegResFQN := createRegisteredResourceValueFQN(f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) - topsecretClassRegResFQN := createRegisteredResourceValueFQN(f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) + rndDeptRegResFQN := createRegisteredResourceValueFQN("", f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) + topsecretClassRegResFQN := createRegisteredResourceValueFQN("", f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) resources := []*authz.Resource{ { @@ -1405,8 +1424,8 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { "department": "rnd", }) - rndDeptRegResFQN := createRegisteredResourceValueFQN(f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) - topsecretClassRegResFQN := createRegisteredResourceValueFQN(f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) + rndDeptRegResFQN := createRegisteredResourceValueFQN("", f.deptRegRes.GetName(), f.deptRegRes.GetValues()[0].GetValue()) + topsecretClassRegResFQN := createRegisteredResourceValueFQN("", f.classificationRegRes.GetName(), f.classificationRegRes.GetValues()[0].GetValue()) resources := []*authz.Resource{ { @@ -1766,7 +1785,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { "clearance": "confidential", }) - readConfidentialRegResFQN := createRegisteredResourceValueFQN(readConfidentialRegRes.GetName(), readConfidentialRegRes.GetValues()[0].GetValue()) + readConfidentialRegResFQN := createRegisteredResourceValueFQN("", readConfidentialRegRes.GetName(), readConfidentialRegRes.GetValues()[0].GetValue()) resources := []*authz.Resource{ { @@ -1801,7 +1820,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { "country": []any{"uk"}, }) - readCountryUKRegResFQN := createRegisteredResourceValueFQN(f.countryRegRes.GetName(), f.countryRegRes.GetValues()[1].GetValue()) + readCountryUKRegResFQN := createRegisteredResourceValueFQN("", f.countryRegRes.GetName(), f.countryRegRes.GetValues()[1].GetValue()) resources := []*authz.Resource{ { @@ -2905,7 +2924,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { s.Require().NotNil(pdp) s.Run("Multiple resources and entitled actions/attributes - full access", func() { - entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") + entityRegResFQN := createRegisteredResourceValueFQN("", regResS3BucketEntity.GetName(), "ts-engineering") resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN, testNetworkPrivateFQN, testNetworkPublicFQN) decision, _, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) @@ -2930,7 +2949,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { }) s.Run("Multiple resources and entitled actions/attributes of varied casing - full access", func() { - entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") + entityRegResFQN := createRegisteredResourceValueFQN("", regResS3BucketEntity.GetName(), "ts-engineering") secretFQN := strings.ToUpper(testClassSecretFQN) networkPrivateFQN := strings.ToUpper(testNetworkPrivateFQN) @@ -2958,7 +2977,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { }) s.Run("Multiple resources and unentitled attributes - full denial", func() { - entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "confidential-finance") + entityRegResFQN := createRegisteredResourceValueFQN("", regResS3BucketEntity.GetName(), "confidential-finance") resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN, testNetworkPrivateFQN, testNetworkPublicFQN) @@ -2989,7 +3008,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { }) s.Run("Multiple resources and unentitled actions - full denial", func() { - entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") + entityRegResFQN := createRegisteredResourceValueFQN("", regResS3BucketEntity.GetName(), "ts-engineering") resources := createResourcePerFqn(testDeptEngineeringFQN, testClassSecretFQN, testNetworkPrivateFQN, testNetworkPublicFQN) @@ -3011,7 +3030,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { }) s.Run("Multiple resources - partial access", func() { - entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "secret-engineering") + entityRegResFQN := createRegisteredResourceValueFQN("", regResS3BucketEntity.GetName(), "secret-engineering") resources := createResourcePerFqn(testClassSecretFQN, testDeptFinanceFQN, testNetworkPrivateFQN, testNetworkPublicFQN) @@ -3478,7 +3497,7 @@ func (s *PDPTestSuite) Test_GetEntitlementsRegisteredResource() { }) s.Run("Valid but non-existent registered resource value FQN", func() { - validButNonexistentFQN := createRegisteredResourceValueFQN("test-res-not-exist", "test-value-not-exist") + validButNonexistentFQN := createRegisteredResourceValueFQN("", "test-res-not-exist", "test-value-not-exist") entitlements, err := pdp.GetEntitlementsRegisteredResource( s.T().Context(), validButNonexistentFQN, @@ -3905,7 +3924,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_NonExistentFQN() { "clearance": "secret", }) - nonExistentRegResFQN := createRegisteredResourceValueFQN("special-system", "classified") + nonExistentRegResFQN := createRegisteredResourceValueFQN("", "special-system", "classified") resources := []*authz.Resource{ { Resource: &authz.Resource_RegisteredResourceValueFqn{ @@ -3934,7 +3953,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_NonExistentFQN() { "clearance": "secret", }) - nonExistentRegResFQN := createRegisteredResourceValueFQN("secret-system", "classified") + nonExistentRegResFQN := createRegisteredResourceValueFQN("", "secret-system", "classified") resources := []*authz.Resource{ { Resource: &authz.Resource_RegisteredResourceValueFqn{ From 44af78f5b5967d9e990989ddaa0e728188a0828d Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 15:17:22 -0400 Subject: [PATCH 26/39] add basic attribute rule tests --- tests-bdd/features/attribute-rules.feature | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests-bdd/features/attribute-rules.feature diff --git a/tests-bdd/features/attribute-rules.feature b/tests-bdd/features/attribute-rules.feature new file mode 100644 index 0000000000..e88e5427be --- /dev/null +++ b/tests-bdd/features/attribute-rules.feature @@ -0,0 +1,94 @@ +@authorization @attribute-rules +Feature: Attribute Rule Decisioning + Validate basic anyOf, allOf, and hierarchy rule behavior in decisioning. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + | project | ["alpha","beta"] | + | sensitivity | ["high"] | + And an empty local platform + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + | ns1 | project | allOf | alpha,beta | + | ns1 | sensitivity | hierarchy | critical,high,medium,low | + Then the response should be successful + And a condition group referenced as "cg_department_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a condition group referenced as "cg_project_alpha" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.project[] | in | alpha | + And a condition group referenced as "cg_project_beta" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.project[] | in | beta | + And a condition group referenced as "cg_sensitivity_high" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.sensitivity[] | in | high | + And a subject set referenced as "ss_department_eng" containing the condition groups "cg_department_eng" + And a subject set referenced as "ss_project_alpha" containing the condition groups "cg_project_alpha" + And a subject set referenced as "ss_project_beta" containing the condition groups "cg_project_beta" + And a subject set referenced as "ss_sensitivity_high" containing the condition groups "cg_sensitivity_high" + And I send a request to create a subject condition set referenced as "scs_department_eng" containing subject sets "ss_department_eng" + And I send a request to create a subject condition set referenced as "scs_project_alpha" containing subject sets "ss_project_alpha" + And I send a request to create a subject condition set referenced as "scs_project_beta" containing subject sets "ss_project_beta" + And I send a request to create a subject condition set referenced as "scs_sensitivity_high" containing subject sets "ss_sensitivity_high" + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: anyOf permits when at least one value is entitled + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng,https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: anyOf denies when no values are entitled + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: allOf denies when any value lacks entitlement + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/project/value/alpha,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: allOf permits when all values are entitled + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + | sm_project_beta | https://example.com/attr/project/value/beta | scs_project_beta | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/project/value/alpha,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: hierarchy permits when entitled to a higher value + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_sensitivity_high | https://example.com/attr/sensitivity/value/high | scs_sensitivity_high | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/sensitivity/value/medium" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: hierarchy denies when resource is higher than entitled value + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_sensitivity_high | https://example.com/attr/sensitivity/value/high | scs_sensitivity_high | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/sensitivity/value/critical" + Then the response should be successful + And I should get a "DENY" decision response From 0f57432342d1f92db97bcd421e8475649e4e466d Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 15:38:20 -0400 Subject: [PATCH 27/39] move authz out of obl, add multi-resource tests --- tests-bdd/cukes/steps_authorization.go | 151 ++++++++++++++++++++++ tests-bdd/cukes/steps_obligations.go | 90 ------------- tests-bdd/features/multi-resource.feature | 69 ++++++++++ 3 files changed, 220 insertions(+), 90 deletions(-) create mode 100644 tests-bdd/features/multi-resource.feature diff --git a/tests-bdd/cukes/steps_authorization.go b/tests-bdd/cukes/steps_authorization.go index 33882d3712..631989b633 100644 --- a/tests-bdd/cukes/steps_authorization.go +++ b/tests-bdd/cukes/steps_authorization.go @@ -115,6 +115,74 @@ func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChai return ctx, nil } +// Step: I send a multi-resource decision request for entity chain "id" for "action" action on resources: (table) +func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + // Build entity chain from stored v2 entities + var entities []*entity.Entity + for _, entityID := range strings.Split(entityChainID, ",") { + ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) + if !ok { + return ctx, fmt.Errorf("entity %s not found or invalid type", entityID) + } + entities = append(entities, ent) + } + + entityChain := &entity.EntityChain{ + Entities: entities, + } + + // Parse resource FQNs from table + var resources []*authzV2.Resource + resourceFQNMap := make(map[string]string) // map ephemeral ID to FQN + resourceIdx := 0 + for ri, row := range tbl.Rows { + if ri == 0 { + continue // Skip header + } + for _, cell := range row.Cells { + fqn := strings.TrimSpace(cell.Value) + ephemeralID := fmt.Sprintf("resource%d", resourceIdx) + resourceFQNMap[ephemeralID] = fqn + resources = append(resources, &authzV2.Resource{ + EphemeralId: ephemeralID, + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: []string{fqn}, + }, + }, + }) + resourceIdx++ + } + } + + // Create v2 multi-resource decision request + req := &authzV2.GetDecisionMultiResourceRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{ + EntityChain: entityChain, + }, + }, + Action: &policy.Action{ + Name: strings.ToLower(action), + }, + Resources: resources, + // For testing purposes, we declare that we can fulfill all obligations + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) + + scenarioContext.SetError(err) + scenarioContext.RecordObject(multiDecisionResponseKey, resp) + scenarioContext.RecordObject(decisionResponse, resp) + scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) + + return ctx, nil +} + // Send decision request using v2 API (with obligations support) func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2(ctx context.Context, scenarioContext *PlatformScenarioContext, entityChainID string, action string, resource string) error { // Build entity chain from stored v2 entities @@ -185,6 +253,26 @@ func getAllObligationsFromScenario(scenarioContext *PlatformScenarioContext) []s return obligationFQNs } +// Step: I should get N decision responses +func (s *AuthorizationServiceStepDefinitions) iShouldGetNDecisionResponses(ctx context.Context, count int) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + decisionRespV2, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + decisionRespV2, ok = scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + return ctx, errors.New("multi-decision response not found or invalid") + } + } + + actualCount := len(decisionRespV2.GetResourceDecisions()) + if actualCount != count { + return ctx, fmt.Errorf("expected %d decision responses, got %d", count, actualCount) + } + + return ctx, nil +} + func (s *AuthorizationServiceStepDefinitions) iShouldGetADecisionResponse(ctx context.Context, expectedResponse string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -214,10 +302,73 @@ func (s *AuthorizationServiceStepDefinitions) iShouldGetADecisionResponse(ctx co return ctx, errors.New("decision response not found or invalid") } +// Step: the multi-resource decision should be "PERMIT" or "DENY" +func (s *AuthorizationServiceStepDefinitions) theMultiResourceDecisionShouldBe(ctx context.Context, expectedDecision string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + resp, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + resp, ok = scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + return ctx, errors.New("multi-decision response not found or invalid") + } + } + + allPermitted := resp.GetAllPermitted() + if allPermitted == nil { + return ctx, errors.New("multi-decision missing all_permitted flag") + } + + expected := strings.EqualFold(expectedDecision, "PERMIT") + if allPermitted.GetValue() != expected { + return ctx, fmt.Errorf("unexpected multi-decision result: got %v expected %v", allPermitted.GetValue(), expected) + } + + return ctx, nil +} + +// Step: the decision response for resource FQN should be "PERMIT" or "DENY" +func (s *AuthorizationServiceStepDefinitions) theDecisionResponseForResourceShouldBe(ctx context.Context, resourceFQN string, expectedDecision string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + decisionRespV2, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + decisionRespV2, ok = scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + return ctx, errors.New("multi-decision response not found or invalid") + } + } + + resourceFQNMap, ok := scenarioContext.GetObject("resourceFQNMap").(map[string]string) + if !ok || len(resourceFQNMap) == 0 { + return ctx, errors.New("resourceFQNMap not found or empty") + } + + expectedDecision = "DECISION_" + strings.ToUpper(strings.TrimSpace(expectedDecision)) + for _, rd := range decisionRespV2.GetResourceDecisions() { + if fqn, exists := resourceFQNMap[rd.GetEphemeralResourceId()]; exists && fqn == resourceFQN { + actualDecision := rd.GetDecision().String() + if actualDecision != expectedDecision { + return ctx, fmt.Errorf("unexpected decision for resource %s: %s instead of %s", resourceFQN, actualDecision, expectedDecision) + } + return ctx, nil + } + } + + known := make([]string, 0, len(resourceFQNMap)) + for _, fqn := range resourceFQNMap { + known = append(known, fqn) + } + return ctx, fmt.Errorf("resource %s not found in decision responses (known: %v)", resourceFQN, known) +} + func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) { stepDefinitions := AuthorizationServiceStepDefinitions{} ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs) ctx.Step(`^there is a "([^"]*)" environment entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsAEnvEntityWithValueAndReferencedAs) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResource) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources) ctx.Step(`^I should get a "([^"]*)" decision response$`, stepDefinitions.iShouldGetADecisionResponse) + ctx.Step(`^I should get (\d+) decision responses$`, stepDefinitions.iShouldGetNDecisionResponses) + ctx.Step(`^the multi-resource decision should be "([^"]*)"$`, stepDefinitions.theMultiResourceDecisionShouldBe) + ctx.Step(`^the decision response for resource "([^"]*)" should be "([^"]*)"$`, stepDefinitions.theDecisionResponseForResourceShouldBe) } diff --git a/tests-bdd/cukes/steps_obligations.go b/tests-bdd/cukes/steps_obligations.go index 710d96e226..3d26ae98e5 100644 --- a/tests-bdd/cukes/steps_obligations.go +++ b/tests-bdd/cukes/steps_obligations.go @@ -9,7 +9,6 @@ import ( "github.com/cucumber/godog" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/common" - "github.com/opentdf/platform/protocol/go/entity" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/policy/obligations" ) @@ -270,92 +269,6 @@ func (s *ObligationsStepDefinitions) theDecisionResponseShouldNotContainObligati return ctx, errors.New("decision response not found or invalid") } -// Step: I send a multi-resource decision request for entity chain "id" for "action" action on resources: (table) -func (s *ObligationsStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { - scenarioContext := GetPlatformScenarioContext(ctx) - scenarioContext.ClearError() - - // Build entity chain from stored v2 entities - var entities []*entity.Entity - for _, entityID := range strings.Split(entityChainID, ",") { - ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) - if !ok { - return ctx, fmt.Errorf("entity %s not found or invalid type", entityID) - } - entities = append(entities, ent) - } - - entityChain := &entity.EntityChain{ - Entities: entities, - } - - // Parse resource FQNs from table - var resources []*authzV2.Resource - resourceFQNMap := make(map[string]string) // map ephemeral ID to FQN - resourceIdx := 0 - for ri, row := range tbl.Rows { - if ri == 0 { - continue // Skip header - } - for _, cell := range row.Cells { - fqn := strings.TrimSpace(cell.Value) - ephemeralID := fmt.Sprintf("resource%d", resourceIdx) - resourceFQNMap[ephemeralID] = fqn - resources = append(resources, &authzV2.Resource{ - EphemeralId: ephemeralID, - Resource: &authzV2.Resource_AttributeValues_{ - AttributeValues: &authzV2.Resource_AttributeValues{ - Fqns: []string{fqn}, - }, - }, - }) - resourceIdx++ - } - } - - // Create v2 multi-resource decision request - req := &authzV2.GetDecisionMultiResourceRequest{ - EntityIdentifier: &authzV2.EntityIdentifier{ - Identifier: &authzV2.EntityIdentifier_EntityChain{ - EntityChain: entityChain, - }, - }, - Action: &policy.Action{ - Name: strings.ToLower(action), - }, - Resources: resources, - // For testing purposes, we declare that we can fulfill all obligations - FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), - } - - resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) - - scenarioContext.SetError(err) - scenarioContext.RecordObject(multiDecisionResponseKey, resp) - scenarioContext.RecordObject("decisionResponse", resp) // Also store as single response for compatibility - scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) // Store mapping for validation - - return ctx, nil -} - -// Step: I should get N decision responses -func (s *ObligationsStepDefinitions) iShouldGetNDecisionResponses(ctx context.Context, count int) (context.Context, error) { - scenarioContext := GetPlatformScenarioContext(ctx) - - // Check v2 multi-resource response - decisionRespV2, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) - if !ok { - return ctx, errors.New("multi-decision response not found or invalid") - } - - actualCount := len(decisionRespV2.GetResourceDecisions()) - if actualCount != count { - return ctx, fmt.Errorf("expected %d decision responses, got %d", count, actualCount) - } - - return ctx, nil -} - // Step: the decision response for resource FQN should contain obligation func (s *ObligationsStepDefinitions) theDecisionResponseForResourceShouldContainObligation(ctx context.Context, resourceFQN string, obligationFQN string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -472,9 +385,6 @@ func RegisterObligationsStepDefinitions(ctx *godog.ScenarioContext, _ *PlatformT ctx.Step(`^the decision response should not contain obligation "([^"]*)"$`, stepDefinitions.theDecisionResponseShouldNotContainObligation) ctx.Step(`^the decision response should contain obligations:$`, stepDefinitions.theDecisionResponseShouldContainObligations) - // Multi-resource decision steps - ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources) - ctx.Step(`^I should get (\d+) decision responses$`, stepDefinitions.iShouldGetNDecisionResponses) ctx.Step(`^the decision response for resource "([^"]*)" should contain obligation "([^"]*)"$`, stepDefinitions.theDecisionResponseForResourceShouldContainObligation) ctx.Step(`^the decision response for resource "([^"]*)" should not contain any obligations$`, stepDefinitions.theDecisionResponseForResourceShouldNotContainAnyObligations) } diff --git a/tests-bdd/features/multi-resource.feature b/tests-bdd/features/multi-resource.feature new file mode 100644 index 0000000000..38b5017a02 --- /dev/null +++ b/tests-bdd/features/multi-resource.feature @@ -0,0 +1,69 @@ +@authorization @multi-resource +Feature: Multi-resource Decisioning (Non-Obligations) + Validate per-resource decisions and response counts without obligation effects. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + | region | ["us"] | + And an empty local platform + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + | ns1 | region | anyOf | us,eu | + Then the response should be successful + And a condition group referenced as "cg_department_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a condition group referenced as "cg_region_us" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.region[] | in | us | + And a subject set referenced as "ss_department_eng" containing the condition groups "cg_department_eng" + And a subject set referenced as "ss_region_us" containing the condition groups "cg_region_us" + And I send a request to create a subject condition set referenced as "scs_department_eng" containing subject sets "ss_department_eng" + And I send a request to create a subject condition set referenced as "scs_region_us" containing subject sets "ss_region_us" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + | sm_region_us | https://example.com/attr/region/value/us | scs_region_us | read | | + Then the response should be successful + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: All resources permitted + When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: + | resource | + | https://example.com/attr/department/value/eng | + | https://example.com/attr/region/value/us | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "PERMIT" + And the decision response for resource "https://example.com/attr/department/value/eng" should be "PERMIT" + And the decision response for resource "https://example.com/attr/region/value/us" should be "PERMIT" + + Scenario: Mixed permit and deny across resources + When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: + | resource | + | https://example.com/attr/department/value/eng | + | https://example.com/attr/region/value/eu | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "DENY" + And the decision response for resource "https://example.com/attr/department/value/eng" should be "PERMIT" + And the decision response for resource "https://example.com/attr/region/value/eu" should be "DENY" + + Scenario: Action mismatch denies only the non-entitled resource + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng_update | https://example.com/attr/department/value/eng | scs_department_eng | update | | + Then the response should be successful + When I send a multi-resource decision request for entity chain "alice" for "update" action on resources: + | resource | + | https://example.com/attr/department/value/eng | + | https://example.com/attr/region/value/us | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "DENY" + And the decision response for resource "https://example.com/attr/department/value/eng" should be "PERMIT" + And the decision response for resource "https://example.com/attr/region/value/us" should be "DENY" From 58abc348e2da5a7c1ed77dd136e4e70c8006ea10 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 15:57:58 -0400 Subject: [PATCH 28/39] gemini comment, add rr features --- tests-bdd/cukes/steps_registeredresources.go | 135 ++++++++++++++---- tests-bdd/features/attribute-rules.feature | 17 +++ .../features/registered-resources.feature | 82 +++++++++++ 3 files changed, 205 insertions(+), 29 deletions(-) create mode 100644 tests-bdd/features/registered-resources.feature diff --git a/tests-bdd/cukes/steps_registeredresources.go b/tests-bdd/cukes/steps_registeredresources.go index 758c215b42..7bd689f145 100644 --- a/tests-bdd/cukes/steps_registeredresources.go +++ b/tests-bdd/cukes/steps_registeredresources.go @@ -21,6 +21,25 @@ const ( aavPairParts = 2 ) +func resolveRegisteredResourceValueFQN(scenarioContext *PlatformScenarioContext, resourceValueRef string) (string, error) { + resourceValueRef = strings.TrimSpace(resourceValueRef) + if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { + if rrValue.GetResource() == nil { + return "", fmt.Errorf("registered resource value %s missing resource", resourceValueRef) + } + namespaceName := "" + if rrValue.GetResource().GetNamespace() != nil { + namespaceName = rrValue.GetResource().GetNamespace().GetName() + } + return (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: namespaceName, + Name: rrValue.GetResource().GetName(), + Value: rrValue.GetValue(), + }).FQN(), nil + } + return resourceValueRef, nil +} + func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredResourceWith(ctx context.Context, tbl *godog.Table) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() @@ -169,20 +188,9 @@ func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForEntityChain entityChain := &entity.EntityChain{Entities: entities} - resourceValueFQN := strings.TrimSpace(resourceValueRef) - if rrValue, ok := scenarioContext.GetObject(resourceValueFQN).(*policy.RegisteredResourceValue); ok && rrValue != nil { - if rrValue.GetResource() == nil { - return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) - } - namespaceName := "" - if rrValue.GetResource() != nil && rrValue.GetResource().GetNamespace() != nil { - namespaceName = rrValue.GetResource().GetNamespace().GetName() - } - resourceValueFQN = (&identifier.FullyQualifiedRegisteredResourceValue{ - Namespace: namespaceName, - Name: rrValue.GetResource().GetName(), - Value: rrValue.GetValue(), - }).FQN() + resourceValueFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, resourceValueRef) + if err != nil { + return ctx, err } req := &authzV2.GetDecisionRequest{ @@ -209,6 +217,85 @@ func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForEntityChain return ctx, nil } +func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForRegisteredResourceValueEntityForActionOnResource(ctx context.Context, entityValueRef string, action string, resource string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + entityFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, entityValueRef) + if err != nil { + return ctx, err + } + + var resourceFQNs []string + for r := range strings.SplitSeq(resource, ",") { + resourceFQNs = append(resourceFQNs, strings.TrimSpace(r)) + } + + req := &authzV2.GetDecisionRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: entityFQN, + }, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resource: &authzV2.Resource{ + EphemeralId: "resource1", + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: resourceFQNs, + }, + }, + }, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) + if err != nil { + scenarioContext.SetError(err) + return ctx, err + } + + scenarioContext.RecordObject(decisionResponse, resp) + return ctx, nil +} + +func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForRegisteredResourceValueEntityForActionOnRegisteredResourceValue(ctx context.Context, entityValueRef string, action string, resourceValueRef string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + entityFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, entityValueRef) + if err != nil { + return ctx, err + } + resourceFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, resourceValueRef) + if err != nil { + return ctx, err + } + + req := &authzV2.GetDecisionRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: entityFQN, + }, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resource: &authzV2.Resource{ + EphemeralId: "resource1", + Resource: &authzV2.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: resourceFQN, + }, + }, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) + if err != nil { + scenarioContext.SetError(err) + return ctx, err + } + + scenarioContext.RecordObject(decisionResponse, resp) + return ctx, nil +} + func (s *RegisteredResourcesStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnRegisteredResourceValues(ctx context.Context, entityChainID string, action string, resourceValueRefs string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() @@ -227,21 +314,9 @@ func (s *RegisteredResourcesStepDefinitions) iSendAMultiResourceDecisionRequestF resources := make([]*authzV2.Resource, 0) resourceFQNMap := make(map[string]string) for idx, resourceValueRef := range strings.Split(resourceValueRefs, ",") { - resourceValueRef = strings.TrimSpace(resourceValueRef) - resourceValueFQN := resourceValueRef - if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { - if rrValue.GetResource() == nil { - return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) - } - namespaceName := "" - if rrValue.GetResource().GetNamespace() != nil { - namespaceName = rrValue.GetResource().GetNamespace().GetName() - } - resourceValueFQN = (&identifier.FullyQualifiedRegisteredResourceValue{ - Namespace: namespaceName, - Name: rrValue.GetResource().GetName(), - Value: rrValue.GetValue(), - }).FQN() + resourceValueFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, resourceValueRef) + if err != nil { + return ctx, err } ephemeralID := fmt.Sprintf("rrv-%d", idx) @@ -300,6 +375,8 @@ func RegisterRegisteredResourcesStepDefinitions(ctx *godog.ScenarioContext) { ctx.Step(`^I send a request to create a registered resource with:$`, stepDefinitions.iSendARequestToCreateARegisteredResourceWith) ctx.Step(`^I send a request to create a registered resource value with:$`, stepDefinitions.iSendARequestToCreateARegisteredResourceValueWith) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on registered resource value "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnRegisteredResourceValue) + ctx.Step(`^I send a decision request for registered resource value entity "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForRegisteredResourceValueEntityForActionOnResource) + ctx.Step(`^I send a decision request for registered resource value entity "([^"]*)" for "([^"]*)" action on registered resource value "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForRegisteredResourceValueEntityForActionOnRegisteredResourceValue) ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on registered resource values "([^"]*)"$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnRegisteredResourceValues) ctx.Step(`^the multi-resource decision should be "([^"]*)"$`, stepDefinitions.theMultiResourceDecisionShouldBe) } diff --git a/tests-bdd/features/attribute-rules.feature b/tests-bdd/features/attribute-rules.feature index e88e5427be..92fe304d7b 100644 --- a/tests-bdd/features/attribute-rules.feature +++ b/tests-bdd/features/attribute-rules.feature @@ -92,3 +92,20 @@ Feature: Attribute Rule Decisioning When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/sensitivity/value/critical" Then the response should be successful And I should get a "DENY" decision response + + Scenario: multiple attributes must all pass across requests + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "DENY" decision response + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_beta | https://example.com/attr/project/value/beta | scs_project_beta | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "PERMIT" decision response diff --git a/tests-bdd/features/registered-resources.feature b/tests-bdd/features/registered-resources.feature new file mode 100644 index 0000000000..b3b89c4b0d --- /dev/null +++ b/tests-bdd/features/registered-resources.feature @@ -0,0 +1,82 @@ +@authorization @registered-resources +Feature: Registered Resource Decisioning (Non-Namespaced) + Validate registered resource value decisioning without strict namespaced policy. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + And a user exists with username "bob" and email "bob@example.com" and the following attributes: + | name | value | + | department | ["hr"] | + And an empty local platform + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + Then the response should be successful + And a condition group referenced as "cg_department_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a subject set referenced as "ss_department_eng" containing the condition groups "cg_department_eng" + And I send a request to create a subject condition set referenced as "scs_department_eng" containing subject sets "ss_department_eng" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_entity | ns1 | service-a | + | rr_target | ns1 | service-b | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | name | + | rr_legacy | legacy-service | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_entity | rr_entity | primary-eng | read=>https://example.com/attr/department/value/eng | + | rrv_target_prod | rr_target | prod-eng | read=>https://example.com/attr/department/value/eng | + | rrv_target_staging | rr_target | staging-hr | read=>https://example.com/attr/department/value/hr | + | rrv_legacy | rr_legacy | legacy-eng | read=>https://example.com/attr/department/value/eng | + Then the response should be successful + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: Registered resource value as resource permits for entitled user + When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_target_prod" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value as resource denies for non-entitled user + And there is a "user_name" subject entity with value "bob" and referenced as "bob" + When I send a decision request for entity chain "bob" for "read" action on registered resource value "rrv_target_prod" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value as resource permits using legacy FQN + When I send a decision request for entity chain "alice" for "read" action on registered resource value "https://reg_res/legacy-service/value/legacy-eng" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value as resource denies when AAV is not entitled + When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_target_staging" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value as entity permits and denies across requests + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "PERMIT" decision response + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on resource "https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value as entity and resource permits + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on registered resource value "rrv_target_prod" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value as entity and resource denies when resource AAV is not entitled + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on registered resource value "rrv_target_staging" + Then the response should be successful + And I should get a "DENY" decision response From fbc75dbb13fef135c943e946ce96b1e90abc4aaf Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 16:20:40 -0400 Subject: [PATCH 29/39] add more obligations features --- tests-bdd/cukes/steps_authorization.go | 180 ++++++++++++++---- tests-bdd/features/obligations.feature | 96 +++++++++- .../features/registered-resources.feature | 2 +- 3 files changed, 236 insertions(+), 42 deletions(-) diff --git a/tests-bdd/cukes/steps_authorization.go b/tests-bdd/cukes/steps_authorization.go index 631989b633..4ced33b43f 100644 --- a/tests-bdd/cukes/steps_authorization.go +++ b/tests-bdd/cukes/steps_authorization.go @@ -115,47 +115,35 @@ func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChai return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations(ctx context.Context, entityChainID, action, resource, fulfillableObligations string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + obligationFQNs := parseFqnsList(fulfillableObligations) + err := s.sendDecisionRequestV2WithFulfillableObligations(ctx, scenarioContext, entityChainID, action, resource, obligationFQNs) + if err != nil { + return ctx, err + } + + return ctx, nil +} + +func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResourceWithNoFulfillableObligations(ctx context.Context, entityChainID, action, resource string) (context.Context, error) { + return s.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations(ctx, entityChainID, action, resource, "[]") +} + // Step: I send a multi-resource decision request for entity chain "id" for "action" action on resources: (table) func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() - // Build entity chain from stored v2 entities - var entities []*entity.Entity - for _, entityID := range strings.Split(entityChainID, ",") { - ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) - if !ok { - return ctx, fmt.Errorf("entity %s not found or invalid type", entityID) - } - entities = append(entities, ent) - } - - entityChain := &entity.EntityChain{ - Entities: entities, + entityChain, err := buildEntityChainFromIDs(scenarioContext, entityChainID) + if err != nil { + return ctx, err } - // Parse resource FQNs from table - var resources []*authzV2.Resource - resourceFQNMap := make(map[string]string) // map ephemeral ID to FQN - resourceIdx := 0 - for ri, row := range tbl.Rows { - if ri == 0 { - continue // Skip header - } - for _, cell := range row.Cells { - fqn := strings.TrimSpace(cell.Value) - ephemeralID := fmt.Sprintf("resource%d", resourceIdx) - resourceFQNMap[ephemeralID] = fqn - resources = append(resources, &authzV2.Resource{ - EphemeralId: ephemeralID, - Resource: &authzV2.Resource_AttributeValues_{ - AttributeValues: &authzV2.Resource_AttributeValues{ - Fqns: []string{fqn}, - }, - }, - }) - resourceIdx++ - } + resources, resourceFQNMap, err := buildResourcesFromTable(tbl) + if err != nil { + return ctx, err } // Create v2 multi-resource decision request @@ -183,8 +171,54 @@ func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequest return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithNoFulfillableObligations(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { + return s.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithFulfillableObligations(ctx, entityChainID, action, "[]", tbl) +} + +func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithFulfillableObligations(ctx context.Context, entityChainID string, action string, fulfillableObligations string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + entityChain, err := buildEntityChainFromIDs(scenarioContext, entityChainID) + if err != nil { + return ctx, err + } + + resources, resourceFQNMap, err := buildResourcesFromTable(tbl) + if err != nil { + return ctx, err + } + + obligationFQNs := parseFqnsList(fulfillableObligations) + req := &authzV2.GetDecisionMultiResourceRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{ + EntityChain: entityChain, + }, + }, + Action: &policy.Action{ + Name: strings.ToLower(action), + }, + Resources: resources, + FulfillableObligationFqns: obligationFQNs, + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) + + scenarioContext.SetError(err) + scenarioContext.RecordObject(multiDecisionResponseKey, resp) + scenarioContext.RecordObject(decisionResponse, resp) + scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) + + return ctx, nil +} + // Send decision request using v2 API (with obligations support) func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2(ctx context.Context, scenarioContext *PlatformScenarioContext, entityChainID string, action string, resource string) error { + return s.sendDecisionRequestV2WithFulfillableObligations(ctx, scenarioContext, entityChainID, action, resource, getAllObligationsFromScenario(scenarioContext)) +} + +func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2WithFulfillableObligations(ctx context.Context, scenarioContext *PlatformScenarioContext, entityChainID string, action string, resource string, fulfillableObligations []string) error { // Build entity chain from stored v2 entities var entities []*entity.Entity for _, entityID := range strings.Split(entityChainID, ",") { @@ -223,8 +257,7 @@ func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2(ctx context. }, }, }, - // For testing purposes, we declare that we can fulfill all obligations - FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + FulfillableObligationFqns: fulfillableObligations, } resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) @@ -253,6 +286,77 @@ func getAllObligationsFromScenario(scenarioContext *PlatformScenarioContext) []s return obligationFQNs } +func buildEntityChainFromIDs(scenarioContext *PlatformScenarioContext, entityChainID string) (*entity.EntityChain, error) { + var entities []*entity.Entity + for _, entityID := range strings.Split(entityChainID, ",") { + ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) + if !ok { + return nil, fmt.Errorf("entity %s not found or invalid type", entityID) + } + entities = append(entities, ent) + } + + return &entity.EntityChain{Entities: entities}, nil +} + +func buildResourcesFromTable(tbl *godog.Table) ([]*authzV2.Resource, map[string]string, error) { + var resources []*authzV2.Resource + resourceFQNMap := make(map[string]string) + resourceIdx := 0 + for ri, row := range tbl.Rows { + if ri == 0 { + continue + } + for _, cell := range row.Cells { + fqn := strings.TrimSpace(cell.Value) + ephemeralID := fmt.Sprintf("resource%d", resourceIdx) + resourceFQNMap[ephemeralID] = fqn + resources = append(resources, &authzV2.Resource{ + EphemeralId: ephemeralID, + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: []string{fqn}, + }, + }, + }) + resourceIdx++ + } + } + + if len(resources) == 0 { + return nil, nil, errors.New("no resources provided") + } + + return resources, resourceFQNMap, nil +} + +func parseFqnsList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "null") { + return nil + } + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")) + if raw == "" { + return nil + } + } + if raw == "" { + return nil + } + out := make([]string, 0) + for f := range strings.SplitSeq(raw, ",") { + f = strings.TrimSpace(f) + if f != "" { + out = append(out, f) + } + } + if len(out) == 0 { + return nil + } + return out +} + // Step: I should get N decision responses func (s *AuthorizationServiceStepDefinitions) iShouldGetNDecisionResponses(ctx context.Context, count int) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -366,7 +470,11 @@ func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) { ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs) ctx.Step(`^there is a "([^"]*)" environment entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsAEnvEntityWithValueAndReferencedAs) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResource) + ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with fulfillable obligations "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations) + ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with no fulfillable obligations$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithNoFulfillableObligations) ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources with no fulfillable obligations:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithNoFulfillableObligations) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources with fulfillable obligations "([^"]*)":$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithFulfillableObligations) ctx.Step(`^I should get a "([^"]*)" decision response$`, stepDefinitions.iShouldGetADecisionResponse) ctx.Step(`^I should get (\d+) decision responses$`, stepDefinitions.iShouldGetNDecisionResponses) ctx.Step(`^the multi-resource decision should be "([^"]*)"$`, stepDefinitions.theMultiResourceDecisionShouldBe) diff --git a/tests-bdd/features/obligations.feature b/tests-bdd/features/obligations.feature index 0e303b9769..3ed829116a 100644 --- a/tests-bdd/features/obligations.feature +++ b/tests-bdd/features/obligations.feature @@ -34,6 +34,7 @@ Feature: Obligations Decisioning E2E Tests | reference_id | attribute_value | condition_set_name | standard actions | custom actions | | sm_classification_topsecret | https://example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read,update | | | sm_classification_secret | https://example.com/attr/classification/value/secret | scs_clearance_secret | read,update | | + And there is a "user_name" subject entity with value "alice" and referenced as "alice" Scenario: Create obligation definition with value and verify in decision response Given I send a request to create an obligation with: @@ -45,7 +46,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | watermark | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "PERMIT" decision response @@ -91,7 +91,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: | resource | | https://example.com/attr/classification/value/topsecret | @@ -112,7 +111,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | watermark | read | https://example.com/attr/classification/value/secret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" And there is a "client_id" environment entity with value "app-client" and referenced as "app" And there is a "user_name" subject entity with value "bob" and referenced as "bob" When I send a decision request for entity chain "alice,app,bob" for "read" action on resource "https://example.com/attr/classification/value/secret" @@ -132,7 +130,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: | resource | | https://example.com/attr/classification/value/topsecret | @@ -160,7 +157,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "PERMIT" decision response @@ -168,3 +164,93 @@ Feature: Obligations Decisioning E2E Tests | obligation | | https://example.com/obl/drm/value/watermark | | https://example.com/obl/drm/value/prevent_download | + + Scenario: Unfulfilled obligations deny access and return required obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "[]" + Then the response should be successful + And I should get a "DENY" decision response + And the decision response should contain obligation "https://example.com/obl/drm/value/watermark" + + Scenario: Unentitled access denies without returning obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + And there is a "user_name" subject entity with value "bob" and referenced as "bob" + When I send a decision request for entity chain "bob" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "[]" + Then the response should be successful + And I should get a "DENY" decision response + And the decision response should not contain obligation "https://example.com/obl/drm/value/watermark" + + Scenario: Mixed obligations across multiple resources + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a multi-resource decision request for entity chain "alice" for "read" action on resources with fulfillable obligations "[]": + | resource | + | https://example.com/attr/classification/value/topsecret | + | https://example.com/attr/classification/value/secret | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "DENY" + And the decision response for resource "https://example.com/attr/classification/value/topsecret" should be "DENY" + And the decision response for resource "https://example.com/attr/classification/value/secret" should be "PERMIT" + And the decision response for resource "https://example.com/attr/classification/value/topsecret" should contain obligation "https://example.com/obl/drm/value/watermark" + And the decision response for resource "https://example.com/attr/classification/value/secret" should not contain any obligations + + Scenario: Partial fulfillable obligations deny but return all required obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark,prevent_download | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "https://example.com/obl/drm/value/watermark" + Then the response should be successful + And I should get a "DENY" decision response + And the decision response should contain obligations: + | obligation | + | https://example.com/obl/drm/value/watermark | + | https://example.com/obl/drm/value/prevent_download | + + Scenario: All fulfillable obligations permit and return required obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark,prevent_download | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "https://example.com/obl/drm/value/watermark,https://example.com/obl/drm/value/prevent_download" + Then the response should be successful + And I should get a "PERMIT" decision response + And the decision response should contain obligations: + | obligation | + | https://example.com/obl/drm/value/watermark | + | https://example.com/obl/drm/value/prevent_download | diff --git a/tests-bdd/features/registered-resources.feature b/tests-bdd/features/registered-resources.feature index b3b89c4b0d..23871ceebc 100644 --- a/tests-bdd/features/registered-resources.feature +++ b/tests-bdd/features/registered-resources.feature @@ -1,5 +1,5 @@ @authorization @registered-resources -Feature: Registered Resource Decisioning (Non-Namespaced) +Feature: Registered Resource Decisioning Validate registered resource value decisioning without strict namespaced policy. Background: From 588905596228360254e66b639feb91eba1f5ebfc Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 17:02:41 -0400 Subject: [PATCH 30/39] add direct entitlement tests --- .../claims/v2/entity_resolution.go | 146 ++++++++++++- .../claims/v2/entity_resolution_test.go | 65 +++++- .../platform.direct_entitlements.template | 51 +++++ tests-bdd/cukes/steps_authorization.go | 191 +++++++++++++++++- .../features/direct-entitlements.feature | 66 ++++++ 5 files changed, 508 insertions(+), 11 deletions(-) create mode 100644 tests-bdd/cukes/resources/platform.direct_entitlements.template create mode 100644 tests-bdd/features/direct-entitlements.feature diff --git a/service/entityresolution/claims/v2/entity_resolution.go b/service/entityresolution/claims/v2/entity_resolution.go index 41003d7ee5..7350c0afbf 100644 --- a/service/entityresolution/claims/v2/entity_resolution.go +++ b/service/entityresolution/claims/v2/entity_resolution.go @@ -2,11 +2,15 @@ package claims import ( "context" + "errors" "fmt" + "log" "log/slog" "strconv" + "strings" "connectrpc.com/connect" + "github.com/go-viper/mapstructure/v2" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/opentdf/platform/protocol/go/entity" entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" @@ -22,17 +26,30 @@ import ( type EntityResolutionServiceV2 struct { entityresolutionV2.UnimplementedEntityResolutionServiceServer - logger *logger.Logger + logger *logger.Logger + allowDirectEntitlements bool trace.Tracer } -func RegisterClaimsERS(_ config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) { - claimsSVC := EntityResolutionServiceV2{logger: logger} +type ClaimsConfig struct { + AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"` +} + +func RegisterClaimsERS(cfg config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) { + var inputConfig ClaimsConfig + if err := mapstructure.Decode(cfg, &inputConfig); err != nil { + logger.Error("failed to decode claims entity resolution configuration", slog.Any("error", err)) + log.Fatalf("Failed to decode claims entity resolution configuration: %v", err) + } + claimsSVC := EntityResolutionServiceV2{ + logger: logger, + allowDirectEntitlements: inputConfig.AllowDirectEntitlements, + } return claimsSVC, nil } func (s EntityResolutionServiceV2) ResolveEntities(ctx context.Context, req *connect.Request[entityresolutionV2.ResolveEntitiesRequest]) (*connect.Response[entityresolutionV2.ResolveEntitiesResponse], error) { - resp, err := EntityResolution(ctx, req.Msg, s.logger) + resp, err := EntityResolution(ctx, req.Msg, s.logger, s.allowDirectEntitlements) return connect.NewResponse(&resp), err } @@ -63,13 +80,14 @@ func CreateEntityChainsFromTokens( } func EntityResolution(_ context.Context, - req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger, + req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger, allowDirectEntitlements bool, ) (entityresolutionV2.ResolveEntitiesResponse, error) { payload := req.GetEntities() var resolvedEntities []*entityresolutionV2.EntityRepresentation for idx, ident := range payload { entityStruct := &structpb.Struct{} + var directEntitlements []*entityresolutionV2.DirectEntitlement switch ident.GetEntityType().(type) { case *entity.Entity_Claims: claims := ident.GetClaims() @@ -79,6 +97,13 @@ func EntityResolution(_ context.Context, return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("error unpacking anypb.Any to structpb.Struct: %w", err)) } } + if allowDirectEntitlements { + var err error + directEntitlements, err = parseDirectEntitlementsFromClaims(entityStruct) + if err != nil { + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, err) + } + } default: retrievedStruct, err := entityToStructPb(ident) if err != nil { @@ -95,8 +120,9 @@ func EntityResolution(_ context.Context, resolvedEntities = append( resolvedEntities, &entityresolutionV2.EntityRepresentation{ - OriginalId: originialID, - AdditionalProps: []*structpb.Struct{entityStruct}, + OriginalId: originialID, + AdditionalProps: []*structpb.Struct{entityStruct}, + DirectEntitlements: directEntitlements, }, ) } @@ -164,3 +190,109 @@ func entityToStructPb(ident *entity.Entity) (*structpb.Struct, error) { } return &entityStruct, nil } + +func parseDirectEntitlementsFromClaims(entityStruct *structpb.Struct) ([]*entityresolutionV2.DirectEntitlement, error) { + if entityStruct == nil { + return nil, nil + } + claims := entityStruct.AsMap() + rawEntitlements, ok := claims["direct_entitlements"] + if !ok { + rawEntitlements, ok = claims["directEntitlements"] + } + if !ok { + return nil, nil + } + + entitlementList, entitlementsOK := rawEntitlements.([]interface{}) + if !entitlementsOK { + return nil, errors.New("direct_entitlements must be an array") + } + + out := make([]*entityresolutionV2.DirectEntitlement, 0, len(entitlementList)) + for idx, entry := range entitlementList { + entryMap, entryOK := entry.(map[string]interface{}) + if !entryOK { + return nil, fmt.Errorf("direct_entitlements[%d] must be an object", idx) + } + + fqn, err := parseDirectEntitlementFQN(entryMap) + if err != nil { + return nil, fmt.Errorf("direct_entitlements[%d] %w", idx, err) + } + + rawActions, actionsOK := entryMap["actions"] + if !actionsOK { + return nil, fmt.Errorf("direct_entitlements[%d] missing actions", idx) + } + actions, err := parseDirectEntitlementActions(rawActions) + if err != nil { + return nil, fmt.Errorf("direct_entitlements[%d] invalid actions: %w", idx, err) + } + + out = append(out, &entityresolutionV2.DirectEntitlement{ + AttributeValueFqn: fqn, + Actions: actions, + }) + } + + return out, nil +} + +func parseDirectEntitlementFQN(entry map[string]interface{}) (string, error) { + if raw, ok := entry["attribute_value_fqn"]; ok { + if fqn, fqnOK := raw.(string); fqnOK { + fqn = strings.TrimSpace(fqn) + if fqn != "" { + return fqn, nil + } + } + } + if raw, ok := entry["attributeValueFqn"]; ok { + if fqn, fqnOK := raw.(string); fqnOK { + fqn = strings.TrimSpace(fqn) + if fqn != "" { + return fqn, nil + } + } + } + return "", errors.New("missing attribute_value_fqn") +} + +func parseDirectEntitlementActions(raw interface{}) ([]string, error) { + actions := make([]string, 0) + switch typed := raw.(type) { + case []interface{}: + for _, action := range typed { + actionStr, ok := action.(string) + if !ok { + return nil, errors.New("action must be a string") + } + actionStr = strings.TrimSpace(strings.ToLower(actionStr)) + if actionStr != "" { + actions = append(actions, actionStr) + } + } + case []string: + for _, action := range typed { + action = strings.TrimSpace(strings.ToLower(action)) + if action != "" { + actions = append(actions, action) + } + } + case string: + for _, action := range strings.Split(typed, ",") { + action = strings.TrimSpace(strings.ToLower(action)) + if action != "" { + actions = append(actions, action) + } + } + default: + return nil, errors.New("actions must be an array or string") + } + + if len(actions) == 0 { + return nil, errors.New("no actions provided") + } + return actions, nil +} diff --git a/service/entityresolution/claims/v2/entity_resolution_test.go b/service/entityresolution/claims/v2/entity_resolution_test.go index 644bdff4e5..9b0fd7f218 100644 --- a/service/entityresolution/claims/v2/entity_resolution_test.go +++ b/service/entityresolution/claims/v2/entity_resolution_test.go @@ -22,7 +22,7 @@ func Test_ClientResolveEntity(t *testing.T) { req := entityresolutionV2.ResolveEntitiesRequest{} req.Entities = validBody - resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) require.NoError(t, reserr) @@ -44,7 +44,7 @@ func Test_EmailResolveEntity(t *testing.T) { req := entityresolutionV2.ResolveEntitiesRequest{} req.Entities = validBody - resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) require.NoError(t, reserr) @@ -78,7 +78,7 @@ func Test_ClaimsResolveEntity(t *testing.T) { req := entityresolutionV2.ResolveEntitiesRequest{} req.Entities = validBody - resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) require.NoError(t, reserr) @@ -93,6 +93,65 @@ func Test_ClaimsResolveEntity(t *testing.T) { assert.EqualValues(t, 42, propMap["baz"]) } +func Test_ClaimsResolveEntityDirectEntitlements(t *testing.T) { + customclaims := map[string]interface{}{ + "direct_entitlements": []interface{}{ + map[string]interface{}{ + "attribute_value_fqn": "https://example.com/attr/department/value/eng", + "actions": []interface{}{"read", "update"}, + }, + }, + } + structClaims, err := structpb.NewStruct(customclaims) + require.NoError(t, err) + + anyClaims, err := anypb.New(structClaims) + require.NoError(t, err) + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_Claims{Claims: anyClaims}}) + + req := entityresolutionV2.ResolveEntitiesRequest{Entities: validBody} + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), true) + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + require.Len(t, entityRepresentations, 1) + + entitlements := entityRepresentations[0].GetDirectEntitlements() + require.Len(t, entitlements, 1) + assert.Equal(t, "https://example.com/attr/department/value/eng", entitlements[0].GetAttributeValueFqn()) + assert.ElementsMatch(t, []string{"read", "update"}, entitlements[0].GetActions()) +} + +func Test_ClaimsResolveEntityDirectEntitlementsDisabled(t *testing.T) { + customclaims := map[string]interface{}{ + "direct_entitlements": []interface{}{ + map[string]interface{}{ + "attribute_value_fqn": "https://example.com/attr/department/value/eng", + "actions": []interface{}{"read"}, + }, + }, + } + structClaims, err := structpb.NewStruct(customclaims) + require.NoError(t, err) + + anyClaims, err := anypb.New(structClaims) + require.NoError(t, err) + + req := entityresolutionV2.ResolveEntitiesRequest{Entities: []*entity.Entity{ + {EphemeralId: "1234", EntityType: &entity.Entity_Claims{Claims: anyClaims}}, + }} + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + require.Len(t, entityRepresentations, 1) + assert.Empty(t, entityRepresentations[0].GetDirectEntitlements()) +} + func Test_JWTToEntityChainClaims(t *testing.T) { validBody := []*entity.Token{{Jwt: samplejwt}} diff --git a/tests-bdd/cukes/resources/platform.direct_entitlements.template b/tests-bdd/cukes/resources/platform.direct_entitlements.template new file mode 100644 index 0000000000..1c8562909a --- /dev/null +++ b/tests-bdd/cukes/resources/platform.direct_entitlements.template @@ -0,0 +1,51 @@ +authEndpoint: &authEndpoint http://{{ .hostname }}:{{.kcPort }}/auth +issuerEndpoint: &issuerEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}} +tokenEndpoint: &tokenEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}}/protocol/openid-connect/token +entityResolutionServiceUrl: &entityResolutionServiceUrl https://{{ .hostname }}:{{.platformPort }}/entityresolution/resolve +platformEndpoint: &platformEndpoint https://{{.hostname }}:{{.platformPort }} +authRealm: &authRealm {{.authRealm}} +mode: all +logger: + level: debug + type: text + output: stdout +server: + port: {{.platformPort}} + auth: + enabled: true + enforceDPoP: false + audience: *platformEndpoint + issuer: *issuerEndpoint + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard +db: + host: {{ .pgHost }} + port: {{ .pgPort }} + database: {{ .pgDatabase }} + user: postgres + password: changeme + schema: otdf +services: + authorization: + allow_direct_entitlements: true + kas: + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: r1 + alg: rsa:2048 + entityresolution: + mode: claims + allow_direct_entitlements: true + shared: + clientId: otdf-shared + clientSecret: secret + authClientId: otdf-shared-auth + serviceHostName: shared + platformEndpoint: *platformEndpoint + platformAuthEndpoint: *authEndpoint + platformAuthRealm: *authRealm + tokenEndpoint: *tokenEndpoint + # ...other service configs as needed... diff --git a/tests-bdd/cukes/steps_authorization.go b/tests-bdd/cukes/steps_authorization.go index 4ced33b43f..d0e3a36aa9 100644 --- a/tests-bdd/cukes/steps_authorization.go +++ b/tests-bdd/cukes/steps_authorization.go @@ -2,6 +2,7 @@ package cukes import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -12,12 +13,15 @@ import ( "github.com/opentdf/platform/protocol/go/policy" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" ) type AuthorizationServiceStepDefinitions struct{} const ( - decisionResponse = "decisionResponse" + decisionResponse = "decisionResponse" + directEntitlementColumnAttributeFQN = "attribute_value_fqn" + directEntitlementColumnActions = "actions" ) func ConvertInterfaceToAny(jsonData []byte) (*anypb.Any, error) { @@ -103,6 +107,72 @@ func (s *AuthorizationServiceStepDefinitions) thereIsASubjectEntityWithValueAndR return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) thereIsAClaimsSubjectEntityReferencedAsWithDirectEntitlements(ctx context.Context, referenceID string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + directEntitlements, err := parseDirectEntitlementsTable(tbl) + if err != nil { + return ctx, err + } + + claims := map[string]interface{}{ + "direct_entitlements": directEntitlements, + } + structClaims, err := structpb.NewStruct(claims) + if err != nil { + return ctx, err + } + anyClaims, err := anypb.New(structClaims) + if err != nil { + return ctx, err + } + + entity := &entity.Entity{ + EphemeralId: referenceID, + Category: entity.Entity_CATEGORY_SUBJECT, + EntityType: &entity.Entity_Claims{Claims: anyClaims}, + } + scenarioContext.RecordObject(referenceID, entity) + return ctx, nil +} + +func (s *AuthorizationServiceStepDefinitions) iAddClaimsToSubjectEntityWith(ctx context.Context, referenceID string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + entityObj, ok := scenarioContext.GetObject(referenceID).(*entity.Entity) + if !ok || entityObj == nil { + return ctx, fmt.Errorf("entity %s not found or invalid type", referenceID) + } + if entityObj.GetClaims() == nil { + return ctx, errors.New("entity does not contain claims") + } + + claimsStruct := &structpb.Struct{} + if err := entityObj.GetClaims().UnmarshalTo(claimsStruct); err != nil { + return ctx, err + } + claimsMap := claimsStruct.AsMap() + + updates, err := parseClaimsTable(tbl) + if err != nil { + return ctx, err + } + for key, value := range updates { + claimsMap[key] = value + } + + updatedStruct, err := structpb.NewStruct(claimsMap) + if err != nil { + return ctx, err + } + anyClaims, err := anypb.New(updatedStruct) + if err != nil { + return ctx, err + } + + entityObj.EntityType = &entity.Entity_Claims{Claims: anyClaims} + scenarioContext.RecordObject(referenceID, entityObj) + return ctx, nil +} + func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResource(ctx context.Context, entityChainID, action, resource string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -357,6 +427,123 @@ func parseFqnsList(raw string) []string { return out } +func parseDirectEntitlementsTable(tbl *godog.Table) ([]interface{}, error) { + if tbl == nil || len(tbl.Rows) == 0 { + return nil, errors.New("direct entitlements table is empty") + } + + cellMap := map[string]int{} + for ri, row := range tbl.Rows { + if ri == 0 { + for ci, cell := range row.Cells { + cellMap[cell.Value] = ci + } + break + } + } + + attrIdx, ok := cellMap[directEntitlementColumnAttributeFQN] + if !ok { + return nil, fmt.Errorf("direct entitlements table requires column %s", directEntitlementColumnAttributeFQN) + } + actionsIdx, ok := cellMap[directEntitlementColumnActions] + if !ok { + return nil, fmt.Errorf("direct entitlements table requires column %s", directEntitlementColumnActions) + } + + out := make([]interface{}, 0, len(tbl.Rows)-1) + for ri, row := range tbl.Rows { + if ri == 0 { + continue + } + attrFQN := strings.TrimSpace(row.Cells[attrIdx].Value) + if attrFQN == "" { + return nil, errors.New("direct entitlements require attribute_value_fqn values") + } + + rawActions := "" + if actionsIdx < len(row.Cells) { + rawActions = row.Cells[actionsIdx].Value + } + actions := make([]interface{}, 0) + for _, action := range strings.Split(rawActions, ",") { + action = strings.TrimSpace(action) + if action == "" { + continue + } + actions = append(actions, strings.ToLower(action)) + } + if len(actions) == 0 { + return nil, fmt.Errorf("direct entitlement for %s requires actions", attrFQN) + } + + out = append(out, map[string]interface{}{ + "attribute_value_fqn": attrFQN, + "actions": actions, + }) + } + + if len(out) == 0 { + return nil, errors.New("direct entitlements table has no rows") + } + + return out, nil +} + +func parseClaimsTable(tbl *godog.Table) (map[string]interface{}, error) { + if tbl == nil || len(tbl.Rows) == 0 { + return nil, errors.New("claims table is empty") + } + + cellMap := map[string]int{} + for ri, row := range tbl.Rows { + if ri == 0 { + for ci, cell := range row.Cells { + cellMap[cell.Value] = ci + } + break + } + } + + nameIdx, ok := cellMap["name"] + if !ok { + return nil, errors.New("claims table requires column name") + } + valueIdx, ok := cellMap["value"] + if !ok { + return nil, errors.New("claims table requires column value") + } + + out := map[string]interface{}{} + for ri, row := range tbl.Rows { + if ri == 0 { + continue + } + key := strings.TrimSpace(row.Cells[nameIdx].Value) + if key == "" { + return nil, errors.New("claims table requires name values") + } + rawValue := "" + if valueIdx < len(row.Cells) { + rawValue = strings.TrimSpace(row.Cells[valueIdx].Value) + } + + var parsed interface{} + if rawValue != "" { + if err := json.Unmarshal([]byte(rawValue), &parsed); err != nil { + parsed = rawValue + } + } + out[key] = parsed + } + + if len(out) == 0 { + return nil, errors.New("claims table has no rows") + } + + return out, nil +} + // Step: I should get N decision responses func (s *AuthorizationServiceStepDefinitions) iShouldGetNDecisionResponses(ctx context.Context, count int) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -468,6 +655,8 @@ func (s *AuthorizationServiceStepDefinitions) theDecisionResponseForResourceShou func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) { stepDefinitions := AuthorizationServiceStepDefinitions{} ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs) + ctx.Step(`^there is a claims subject entity referenced as "([^"]*)" with direct entitlements:$`, stepDefinitions.thereIsAClaimsSubjectEntityReferencedAsWithDirectEntitlements) + ctx.Step(`^I add claims to subject entity "([^"]*)" with:$`, stepDefinitions.iAddClaimsToSubjectEntityWith) ctx.Step(`^there is a "([^"]*)" environment entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsAEnvEntityWithValueAndReferencedAs) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResource) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with fulfillable obligations "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations) diff --git a/tests-bdd/features/direct-entitlements.feature b/tests-bdd/features/direct-entitlements.feature new file mode 100644 index 0000000000..19d87c925f --- /dev/null +++ b/tests-bdd/features/direct-entitlements.feature @@ -0,0 +1,66 @@ +@authorization @direct-entitlements +Feature: Direct Entitlements Decisioning + Validate direct entitlement evaluation when allow_direct_entitlements is enabled. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + And a local platform with platform template "cukes/resources/platform.direct_entitlements.template" and keycloak template "cukes/resources/keycloak_base.template" + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + | ns1 | project | allOf | alpha,beta | + Then the response should be successful + + Scenario: Direct entitlement permits for matching action + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + 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 "PERMIT" decision response + + Scenario: Direct entitlement denies for action mismatch + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + When I send a decision request for entity chain "alice" for "update" 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: Direct entitlement permits for another value + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/hr | read | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Direct entitlement permits for synthetic value + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/finance | read | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/finance" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Subject mapping and direct entitlements both apply + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/project/value/beta | read | + And I add claims to subject entity "alice" with: + | name | value | + | attributes | {"project":["alpha"]} | + And a condition group referenced as "cg_project_alpha" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.project[] | in | alpha | + And a subject set referenced as "ss_project_alpha" containing the condition groups "cg_project_alpha" + And I send a request to create a subject condition set referenced as "scs_project_alpha" containing subject sets "ss_project_alpha" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/project/value/alpha,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "PERMIT" decision response From 5eb0db3f8842a435044b175cbe02e1328b1658a4 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 17:21:54 -0400 Subject: [PATCH 31/39] better cukes setup with template add specific deny scenarios --- tests-bdd/cukes/steps_localplatform.go | 14 +++++++++++--- .../features/namespaced-decisioning.feature | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tests-bdd/cukes/steps_localplatform.go b/tests-bdd/cukes/steps_localplatform.go index 2f673b9b99..4aa840532d 100644 --- a/tests-bdd/cukes/steps_localplatform.go +++ b/tests-bdd/cukes/steps_localplatform.go @@ -140,7 +140,7 @@ func (s *LocalPlatformStepDefinitions) commonLocalPlatform(ctx context.Context, if !exists { version = platformImageEnvironmentLocalImage } - platformConfigPath, err := createPlatformConfiguration(localPlatformOptions, scenarioContext.ScenarioOptions, version == debugVersion) + platformConfigPath, err := createPlatformConfiguration(localPlatformOptions, scenarioContext.ScenarioOptions, version == debugVersion, options.platformProvisionPath) if err != nil { return ctx, err } @@ -397,7 +397,7 @@ func createPlatformComposeConfiguration(options *LocalDevOptions) (string, error } // createPlatformConfiguration generates a platform configuration from a go text template for platform option settings -func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *LocalDevScenarioOptions, devMode bool) (string, error) { +func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *LocalDevScenarioOptions, devMode bool, platformTemplatePath *string) (string, error) { tempFileName := path.Join(options.CukesDir, "opentdf.yaml") platformKeysDir := options.KeysDir pgHost := "localhost" @@ -405,7 +405,15 @@ func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *Loca platformKeysDir = containerKeyPath pgHost = options.Hostname } - t := template.Must(template.New("platform").Parse(platformTemplate)) + templateSource := platformTemplate + if platformTemplatePath != nil && *platformTemplatePath != "" { + templateBytes, err := os.ReadFile(*platformTemplatePath) + if err != nil { + return tempFileName, err + } + templateSource = string(templateBytes) + } + t := template.Must(template.New("platform").Parse(templateSource)) var strBuffer bytes.Buffer if err := t.Execute(&strBuffer, map[string]any{ "hostname": options.Hostname, diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index 85c4917fee..3d22d0853d 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -32,6 +32,15 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I should get a "PERMIT" decision response + Scenario: Standard action name denies when subject mapping is unnamespaced + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_unns_read_eng | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "DENY" decision response + Scenario: Standard action name denies when entitled only in different namespace And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | @@ -50,6 +59,15 @@ Feature: Namespaced Policy Decisioning (name-only action requests) Then the response should be successful And I should get a "PERMIT" decision response + Scenario: Custom action name denies when subject mapping is unnamespaced + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_unns_export_eng | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | | export | + Then the response should be successful + When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "DENY" decision response + Scenario: Custom action name denies when entitled only in different namespace And I send a request to create a subject mapping with: | reference_id | namespace_id | attribute_value | condition_set_name | standard actions | custom actions | From ef5718904a89a49f15f57d48bd53331c336e659d Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 17:34:40 -0400 Subject: [PATCH 32/39] add un-namespaced sm to feature --- tests-bdd/features/namespaced-decisioning.feature | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests-bdd/features/namespaced-decisioning.feature b/tests-bdd/features/namespaced-decisioning.feature index 3d22d0853d..be8a9e55af 100644 --- a/tests-bdd/features/namespaced-decisioning.feature +++ b/tests-bdd/features/namespaced-decisioning.feature @@ -33,9 +33,10 @@ Feature: Namespaced Policy Decisioning (name-only action requests) And I should get a "PERMIT" decision response Scenario: Standard action name denies when subject mapping is unnamespaced + And I send a request to create a subject condition set referenced as "scs_department_eng_unns" containing subject sets "ss_eng_department" And I send a request to create a subject mapping with: | reference_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_unns_read_eng | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | read | | + | sm_unns_read_eng | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_unns | read | | Then the response should be successful When I send a decision request for entity chain "alice" for "read" action on resource "https://ns-one.example.com/attr/department/value/eng" Then the response should be successful @@ -60,9 +61,10 @@ Feature: Namespaced Policy Decisioning (name-only action requests) And I should get a "PERMIT" decision response Scenario: Custom action name denies when subject mapping is unnamespaced + And I send a request to create a subject condition set referenced as "scs_department_eng_unns" containing subject sets "ss_eng_department" And I send a request to create a subject mapping with: | reference_id | attribute_value | condition_set_name | standard actions | custom actions | - | sm_unns_export_eng | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_ns1 | | export | + | sm_unns_export_eng | https://ns-one.example.com/attr/department/value/eng | scs_department_eng_unns | | export | Then the response should be successful When I send a decision request for entity chain "alice" for "custom_action_export" action on resource "https://ns-one.example.com/attr/department/value/eng" Then the response should be successful From cf102413fda6ed9366c9e22986c8128b6d702d1b Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 10 Apr 2026 17:42:04 -0400 Subject: [PATCH 33/39] lint --- service/entityresolution/claims/v2/entity_resolution.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/entityresolution/claims/v2/entity_resolution.go b/service/entityresolution/claims/v2/entity_resolution.go index 7350c0afbf..002726b501 100644 --- a/service/entityresolution/claims/v2/entity_resolution.go +++ b/service/entityresolution/claims/v2/entity_resolution.go @@ -31,12 +31,12 @@ type EntityResolutionServiceV2 struct { trace.Tracer } -type ClaimsConfig struct { +type Config struct { AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"` } func RegisterClaimsERS(cfg config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) { - var inputConfig ClaimsConfig + var inputConfig Config if err := mapstructure.Decode(cfg, &inputConfig); err != nil { logger.Error("failed to decode claims entity resolution configuration", slog.Any("error", err)) log.Fatalf("Failed to decode claims entity resolution configuration: %v", err) From 57b785cf8e2609284166eceee38dbe98465c5d3c Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Mon, 13 Apr 2026 15:12:40 -0400 Subject: [PATCH 34/39] populate namespace for action for decisioning check --- service/policy/db/queries/registered_resources.sql | 7 ++++++- service/policy/db/registered_resources.sql.go | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/service/policy/db/queries/registered_resources.sql b/service/policy/db/queries/registered_resources.sql index a095ba5f78..f4b9eff839 100644 --- a/service/policy/db/queries/registered_resources.sql +++ b/service/policy/db/queries/registered_resources.sql @@ -108,7 +108,10 @@ LEFT JOIN LATERAL ( JSON_BUILD_OBJECT( 'action', JSON_BUILD_OBJECT( 'id', a.id, - 'name', a.name + 'name', a.name, + 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) + END ), 'attribute_value', JSON_BUILD_OBJECT( 'id', av.id, @@ -120,6 +123,8 @@ LEFT JOIN LATERAL ( -- 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_namespaces ans ON ans.id = a.namespace_id + LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL 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 diff --git a/service/policy/db/registered_resources.sql.go b/service/policy/db/registered_resources.sql.go index 30272f48db..4b539fafb7 100644 --- a/service/policy/db/registered_resources.sql.go +++ b/service/policy/db/registered_resources.sql.go @@ -618,7 +618,10 @@ LEFT JOIN LATERAL ( JSON_BUILD_OBJECT( 'action', JSON_BUILD_OBJECT( 'id', a.id, - 'name', a.name + 'name', a.name, + 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) + END ), 'attribute_value', JSON_BUILD_OBJECT( 'id', av.id, @@ -630,6 +633,8 @@ LEFT JOIN LATERAL ( -- 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_namespaces ans ON ans.id = a.namespace_id + LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL 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 @@ -701,7 +706,10 @@ type listRegisteredResourcesRow struct { // JSON_BUILD_OBJECT( // 'action', JSON_BUILD_OBJECT( // 'id', a.id, -// 'name', a.name +// 'name', a.name, +// 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL +// ELSE JSON_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) +// END // ), // 'attribute_value', JSON_BUILD_OBJECT( // 'id', av.id, @@ -713,6 +721,8 @@ type listRegisteredResourcesRow struct { // -- 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_namespaces ans ON ans.id = a.namespace_id +// LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL // 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 From 1384ee51ca0dec34987482a0efc68f0a0632cfdf Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 15 Apr 2026 15:19:59 -0400 Subject: [PATCH 35/39] move into validators, use fqns instead of id, remove scoped --- service/internal/access/v2/evaluate.go | 130 +++++++----------- service/internal/access/v2/evaluate_test.go | 22 +-- service/internal/access/v2/pdp.go | 24 ++-- service/internal/access/v2/validators.go | 28 +++- service/internal/access/v2/validators_test.go | 118 +++++++++++----- 5 files changed, 180 insertions(+), 142 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index b04437953c..3700916b65 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -40,7 +40,7 @@ func getResourceDecision( var ( resourceID = resource.GetEphemeralId() registeredResourceValueFQN string - requiredNamespace string + requiredNamespaceFqn *identifier.FullyQualifiedAttribute resourceAttributeValues *authz.Resource_AttributeValues failure = &ResourceDecision{ Entitled: false, @@ -48,7 +48,7 @@ func getResourceDecision( ResourceName: resourceID, } ) - if err := validateGetResourceDecision(entitlements, action, resource); err != nil { + if err := validateGetResourceDecision(entitlements, action, resource, namespacedPolicy); err != nil { return nil, err } @@ -64,18 +64,6 @@ func getResourceDecision( l = l.With("registered_resource_value_fqn", registeredResourceValueFQN) failure.ResourceName = registeredResourceValueFQN - // If namespaced policies are enabled, enforce that the registered resource value FQN is namespaced and extract the required namespace for later checks - if namespacedPolicy { - parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) - if err != nil { - return nil, fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) - } - if parsed.Namespace == "" { - return nil, fmt.Errorf("registered resource value FQN must be namespaced in strict mode [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) - } - requiredNamespace = parsed.Namespace - } - regResValue, found := accessibleRegisteredResourceValues[registeredResourceValueFQN] if !found { l.WarnContext( @@ -90,12 +78,17 @@ func getResourceDecision( slog.Any("action_attribute_values", regResValue.GetActionAttributeValues()), ) + if namespacedPolicy { + // the parsing is validated in the validator, so ignoring error here + parsed, _ := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) + requiredNamespaceFqn = &identifier.FullyQualifiedAttribute{Namespace: parsed.Namespace} + } + resourceAttributeValues = &authz.Resource_AttributeValues{ Fqns: make([]string, 0), } for _, aav := range regResValue.GetActionAttributeValues() { aavAttrValueFQN := aav.GetAttributeValue().GetFqn() - var requiredNamespaceID string // If namespaced policies are enabled, enforce that the attribute value FQN is in the same namespace as the registered resource value and extract the namespace ID for later checks. // This is a fail safe, as RR and attr NS match should be enforced on creation and update of registered resources // This ensures that only attribute values from the correct namespace are considered in the evaluation. @@ -104,19 +97,20 @@ func getResourceDecision( if err != nil { return nil, fmt.Errorf("invalid attribute value FQN [%s]: %w", aavAttrValueFQN, ErrInvalidResource) } - if parsed.Namespace != requiredNamespace { - return nil, fmt.Errorf("attribute value FQN [%s] namespace [%s] does not match RR namespace [%s]: %w", aavAttrValueFQN, parsed.Namespace, requiredNamespace, ErrInvalidResource) - } - // Since we dont have the ns id on the RR value, pull it from the attr val - if attrAndValue, ok := accessibleAttributeValues[aavAttrValueFQN]; ok { - requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() - } else { - return nil, fmt.Errorf("AAV attribute value FQN [%s] not found in accessible attributes: %w", aavAttrValueFQN, ErrFQNNotFound) + if parsed.Namespace != requiredNamespaceFqn.Namespace { + return nil, fmt.Errorf("attribute value FQN [%s] namespace [%s] does not match RR namespace [%s]: %w", aavAttrValueFQN, parsed.Namespace, requiredNamespaceFqn.FQN(), ErrInvalidResource) } } // skip evaluating attribute rules on any action-attribute-values without the requested action - if !isRequestedActionMatch(ctx, l, action, requiredNamespaceID, aav.GetAction(), namespacedPolicy) { + if !isRequestedActionMatch(ctx, l, action, + func() string { + if requiredNamespaceFqn != nil && requiredNamespaceFqn.Namespace != "" { + return requiredNamespaceFqn.FQN() + } + return "" + }(), aav.GetAction(), + namespacedPolicy) { continue } @@ -235,7 +229,7 @@ func evaluateDefinition( namespacedPolicy bool, ) (*DataRuleResult, error) { var entitlementFailures []EntitlementFailure - requiredNamespaceID := attrDefinition.GetNamespace().GetId() + namespaceFQN := attrDefinition.GetNamespace().GetFqn() l = l.With("definitionRule", attrDefinition.GetRule().String()) l = l.With("definitionFQN", attrDefinition.GetFqn()) @@ -248,13 +242,13 @@ func evaluateDefinition( switch attrDefinition.GetRule() { case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: - entitlementFailures = allOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) + entitlementFailures = allOfRule(ctx, l, entitlements, action, resourceValueFQNs, namespaceFQN, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: - entitlementFailures = anyOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, requiredNamespaceID, namespacedPolicy) + entitlementFailures = anyOfRule(ctx, l, entitlements, action, resourceValueFQNs, namespaceFQN, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: - entitlementFailures = hierarchyRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, requiredNamespaceID, namespacedPolicy) + entitlementFailures = hierarchyRule(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, namespaceFQN, namespacedPolicy) case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: return nil, fmt.Errorf("%w: %s, rule: %s", ErrMissingRequiredSpecifiedRule, attrDefinition.GetFqn(), attrDefinition.GetRule().String()) @@ -289,18 +283,7 @@ func allOfRule( entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, -) []EntitlementFailure { - // Legacy wrapper: evaluate without strict namespace constraints. - return allOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, "", false) -} - -func allOfRuleScoped( - ctx context.Context, - l *logger.Logger, - entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, - action *policy.Action, - resourceValueFQNs []string, - requiredNamespaceID string, + requiredNamespaceFQN string, namespacedPolicy bool, ) []EntitlementFailure { actionName := action.GetName() @@ -313,7 +296,7 @@ func allOfRuleScoped( // Check if this FQN has the entitled action if entitledActions, ok := entitlements[valueFQN]; ok { for _, entitledAction := range entitledActions { - if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceFQN, entitledAction, namespacedPolicy) { hasEntitlement = true break } @@ -342,18 +325,7 @@ func anyOfRule( entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resourceValueFQNs []string, -) []EntitlementFailure { - // Legacy wrapper: evaluate without strict namespace constraints. - return anyOfRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, "", false) -} - -func anyOfRuleScoped( - ctx context.Context, - l *logger.Logger, - entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, - action *policy.Action, - resourceValueFQNs []string, - requiredNamespaceID string, + requiredNamespaceFQN string, namespacedPolicy bool, ) []EntitlementFailure { // No resources to check @@ -372,7 +344,7 @@ func anyOfRuleScoped( entitledActions, ok := entitlements[valueFQN] if ok { for _, entitledAction := range entitledActions { - if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceFQN, entitledAction, namespacedPolicy) { foundEntitlementForThisFQN = true anyEntitlementFound = true break @@ -407,19 +379,7 @@ func hierarchyRule( action *policy.Action, resourceValueFQNs []string, attrDefinition *policy.Attribute, -) []EntitlementFailure { - // Legacy wrapper: evaluate without strict namespace constraints. - return hierarchyRuleScoped(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, "", false) -} - -func hierarchyRuleScoped( - ctx context.Context, - l *logger.Logger, - entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, - action *policy.Action, - resourceValueFQNs []string, - attrDefinition *policy.Attribute, - requiredNamespaceID string, + requiredNamespaceFQN string, namespacedPolicy bool, ) []EntitlementFailure { // No resources to check @@ -451,7 +411,7 @@ func hierarchyRuleScoped( if idx, exists := valueFQNToIndex[entitlementFQN]; exists && idx <= lowestValueFQNIndex { // Check if the required action is entitled for _, entitledAction := range entitledActions { - if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceFQN, entitledAction, namespacedPolicy) { l.DebugContext(ctx, "hierarchy rule satisfied", slog.Group("entitled_by_value", slog.String("FQN", entitlementFQN), @@ -474,7 +434,7 @@ func hierarchyRuleScoped( foundValue := false if entitledActions, ok := entitlements[valueFQN]; ok { for _, entitledAction := range entitledActions { - if isRequestedActionMatch(ctx, l, action, requiredNamespaceID, entitledAction, namespacedPolicy) { + if isRequestedActionMatch(ctx, l, action, requiredNamespaceFQN, entitledAction, namespacedPolicy) { foundValue = true break } @@ -492,7 +452,11 @@ func hierarchyRuleScoped( return entitlementFailures } -func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedAction *policy.Action, requiredNamespaceID string, entitledAction *policy.Action, namespacedPolicy bool) bool { +// This function checks if there are any conflicts between two actions based on their IDs and namespaces, and determines which action to prefer in case of a conflict. +// This is used when merging actions from different sources (e.g., direct entitlements and subject mapping) to ensure deterministic behavior. +// The requestedAction is the action from the access request, and the entitledAction is the action from the entitlements. +// The requiredNamespaceID and namespacedPolicy parameters are used to enforce namespace constraints if strict namespaced policies are enabled. +func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedAction *policy.Action, requiredNamespaceFQN string, entitledAction *policy.Action, namespacedPolicy bool) bool { if requestedAction == nil || entitledAction == nil { return false } @@ -516,14 +480,20 @@ func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedActi // If the caller explicitly provides a request action namespace, always enforce // that identity constraint regardless of namespacedPolicy mode. if requestNamespace := requestedAction.GetNamespace(); requestNamespace != nil && (requestNamespace.GetId() != "" || requestNamespace.GetFqn() != "") { + // the requested action has a namespace, so enforce that the entitled action also has a + // namespace and that they match on id if provided, otherwise fqn (case-insensitive) entitledNamespace := entitledAction.GetNamespace() if entitledNamespace == nil { + // the entitled action is missing a namespace while the request action has one, + // so this is a mismatch and we should not consider this a match l.TraceContext(ctx, "action match request namespace mismatch", slog.String("requested_action_namespace_id", requestNamespace.GetId()), ) return false } if requestNamespace.GetId() != "" && entitledNamespace.GetId() != requestNamespace.GetId() { + // the requested action namespace has an id and it does not match the entitled action namespace id, + // so this is a mismatch and we should not consider this a match l.TraceContext(ctx, "action match request namespace mismatch", slog.String("requested_action_namespace_id", requestNamespace.GetId()), slog.String("candidate_namespace_id", entitledNamespace.GetId()), @@ -531,6 +501,8 @@ func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedActi return false } if requestNamespace.GetId() == "" && requestNamespace.GetFqn() != "" && !strings.EqualFold(entitledNamespace.GetFqn(), requestNamespace.GetFqn()) { + // the requested action namespace has an FQN and it does not match the entitled action namespace FQN, + // so this is a mismatch and we should not consider this a match l.TraceContext(ctx, "action match request namespace mismatch", slog.String("requested_action_namespace_fqn", requestNamespace.GetFqn()), slog.String("candidate_namespace_fqn", entitledNamespace.GetFqn()), @@ -545,24 +517,26 @@ func isRequestedActionMatch(ctx context.Context, l *logger.Logger, requestedActi // Strict namespaced-policy mode requires a resolved target namespace from // the resource/definition context and a namespaced entitled action. - if requiredNamespaceID == "" { - l.TraceContext(ctx, "action match strict namespace mismatch", - slog.String("required_namespace_id", requiredNamespaceID), - ) + if requiredNamespaceFQN == "" { + // we are in strict namespaced policy mode but do not have a required namespace from the resource context + // this should not happen, it should be caught upstream + l.TraceContext(ctx, "action match strict namespace mismatch, required_namespace is empty") return false } entitledNamespace := entitledAction.GetNamespace() if entitledNamespace == nil || entitledNamespace.GetId() == "" { + // the entitled action is missing a namespace while strict namespaced policy mode requires it l.TraceContext(ctx, "action match strict namespace mismatch", - slog.String("required_namespace_id", requiredNamespaceID), + slog.String("required_namespace", requiredNamespaceFQN), ) return false } - if entitledNamespace.GetId() != requiredNamespaceID { + if entitledNamespace.GetFqn() != requiredNamespaceFQN { + // the entitled action namespace FQN does not match the required namespace FQN from the resource context l.TraceContext(ctx, "action match strict namespace mismatch", - slog.String("required_namespace_id", requiredNamespaceID), - slog.String("candidate_namespace_id", entitledNamespace.GetId()), + slog.String("required_namespace", requiredNamespaceFQN), + slog.String("candidate_namespace", entitledNamespace.GetFqn()), ) return false } diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 1a5a8f25d9..5131fb4c6a 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -335,7 +335,7 @@ func (s *EvaluateTestSuite) TestAllOfRule() { for _, tc := range tests { s.Run(tc.name, func() { - failures := allOfRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs) + failures := allOfRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, "", false) s.Len(failures, tc.expectedFailures) @@ -469,7 +469,7 @@ func (s *EvaluateTestSuite) TestAnyOfRule() { for _, tc := range tests { s.Run(tc.name, func() { // Execute - failures := anyOfRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs) + failures := anyOfRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, "", false) // Assert if tc.expectedFailCount == 0 { @@ -649,7 +649,7 @@ func (s *EvaluateTestSuite) TestHierarchyRule() { for _, tc := range tests { s.Run(tc.name, func() { // Execute - failures := hierarchyRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, s.hierarchicalClassAttr) + failures := hierarchyRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, s.hierarchicalClassAttr, "", false) // Assert if tc.expectedFailures { @@ -1550,7 +1550,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "nil requested action", requestedAction: nil, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, namespacedPolicy: true, expectedActionMatch: false, @@ -1558,7 +1558,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "id precedence denies same-name different-id", requestedAction: &policy.Action{Id: "requested-id", Name: actions.ActionNameRead}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Id: "entitled-id", Name: actions.ActionNameRead, Namespace: namespaceA}, namespacedPolicy: true, expectedActionMatch: false, @@ -1566,7 +1566,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "name is case-insensitive in legacy mode", requestedAction: &policy.Action{Name: "READ"}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: "read"}, namespacedPolicy: false, expectedActionMatch: true, @@ -1574,7 +1574,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "request namespace id must match entitled namespace id", requestedAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceB}, namespacedPolicy: false, expectedActionMatch: false, @@ -1582,7 +1582,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "request namespace fqn must match entitled namespace fqn", requestedAction: &policy.Action{Name: actions.ActionNameRead, Namespace: &policy.Namespace{Fqn: "https://ns-a.example.com"}}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: &policy.Namespace{Id: namespaceA.GetId(), Fqn: "HTTPS://NS-A.EXAMPLE.COM"}}, namespacedPolicy: false, expectedActionMatch: true, @@ -1598,7 +1598,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "strict mode requires entitled action namespace id", requestedAction: &policy.Action{Name: actions.ActionNameRead}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: actions.ActionNameRead}, namespacedPolicy: true, expectedActionMatch: false, @@ -1606,7 +1606,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "strict mode allows matching namespace id", requestedAction: &policy.Action{Name: actions.ActionNameRead}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceA}, namespacedPolicy: true, expectedActionMatch: true, @@ -1614,7 +1614,7 @@ func Test_isRequestedActionMatch(t *testing.T) { { name: "strict mode denies mismatched namespace id", requestedAction: &policy.Action{Name: actions.ActionNameRead}, - requiredNamespace: namespaceA.GetId(), + requiredNamespace: namespaceA.GetFqn(), entitledAction: &policy.Action{Name: actions.ActionNameRead, Namespace: namespaceB}, namespacedPolicy: true, expectedActionMatch: false, diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 8a95d99963..63cae6031c 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -286,7 +286,10 @@ func (p *PolicyDecisionPoint) GetDecision( // Merge direct-entitlement actions with subject-mapping actions for the // same value FQN instead of replacing them. - actions := entitledFQNsToActions[fqn] + actions, ok := entitledFQNsToActions[fqn] + if !ok { + actions = make([]*policy.Action, len(actionNames)) + } for _, name := range actionNames { actions = append(actions, &policy.Action{ Name: name, @@ -336,18 +339,9 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( 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 { + if err := validateGetDecisionRegisteredResource(entityRegisteredResourceValueFQN, action, resources, p.namespacedPolicy); err != nil { return nil, nil, err } - if p.namespacedPolicy { - parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](entityRegisteredResourceValueFQN) - if err != nil { - return nil, nil, fmt.Errorf("invalid registered resource value FQN [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) - } - if parsed.Namespace == "" { - return nil, nil, fmt.Errorf("registered resource value FQN must be namespaced in strict mode [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) - } - } entityRegisteredResourceValue, ok := p.allRegisteredResourceValuesByFQN[entityRegisteredResourceValueFQN] if !ok { @@ -375,16 +369,16 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( attrVal := aav.GetAttributeValue() attrValFQN := attrVal.GetFqn() - requiredNamespaceID := "" + requiredNamespaceFQN := "" if attrAndValue, ok2 := decisionableAttributes[attrValFQN]; ok2 { - requiredNamespaceID = attrAndValue.GetAttribute().GetNamespace().GetId() + requiredNamespaceFQN = attrAndValue.GetAttribute().GetNamespace().GetFqn() } - if !isRequestedActionMatch(ctx, l, action, requiredNamespaceID, aavAction, p.namespacedPolicy) { + if !isRequestedActionMatch(ctx, l, action, requiredNamespaceFQN, aavAction, p.namespacedPolicy) { l.DebugContext(ctx, "skipping action not matching Decision Request action", slog.String("action_name", aavAction.GetName()), slog.String("attribute_value_fqn", attrValFQN), - slog.String("required_namespace_id", requiredNamespaceID), + slog.String("required_namespace", requiredNamespaceFQN), ) continue } diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index 12cfe54285..f4e10dd345 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/opentdf/platform/lib/identifier" + authz "github.com/opentdf/platform/protocol/go/authorization/v2" 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" @@ -45,7 +46,7 @@ func validateGetDecision(entityRepresentation *entityresolutionV2.EntityRepresen // - 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 -func validateGetDecisionRegisteredResource(registeredResourceValueFQN string, action *policy.Action, resources []*authzV2.Resource) error { +func validateGetDecisionRegisteredResource(registeredResourceValueFQN string, action *policy.Action, resources []*authzV2.Resource, namespacedPolicy bool) error { if _, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN); err != nil { return err } @@ -60,6 +61,15 @@ func validateGetDecisionRegisteredResource(registeredResourceValueFQN string, ac return fmt.Errorf("resource is nil: %w", ErrInvalidResource) } } + if namespacedPolicy { + parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) + if err != nil { + return fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) + } + if parsed.Namespace == "" { + return fmt.Errorf("registered resource value FQN must be namespaced in strict mode [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) + } + } return nil } @@ -175,6 +185,7 @@ func validateGetResourceDecision( entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, resource *authzV2.Resource, + namespacedPolicy bool, ) error { if entitlements == nil { return fmt.Errorf("entitled FQNs to actions are nil: %w", ErrInvalidEntitledFQNsToActions) @@ -185,5 +196,20 @@ func validateGetResourceDecision( if resource.GetResource() == nil { return fmt.Errorf("resource is nil: %w", ErrInvalidResource) } + if namespacedPolicy { + switch resource.GetResource().(type) { + case *authz.Resource_RegisteredResourceValueFqn: + registeredResourceValueFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) + // If namespaced policies are enabled, enforce that the registered resource value FQN is namespaced and extract the required namespace for later checks + parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) + + if err != nil { + return fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) + } + if parsed.Namespace == "" { + return fmt.Errorf("registered resource value FQN must be namespaced in strict mode [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) + } + } + } return nil } diff --git a/service/internal/access/v2/validators_test.go b/service/internal/access/v2/validators_test.go index 4da9a23036..ea56660f9b 100644 --- a/service/internal/access/v2/validators_test.go +++ b/service/internal/access/v2/validators_test.go @@ -421,60 +421,81 @@ func TestValidateGetResourceDecision(t *testing.T) { }, } + validRRResource := &authzV2.Resource{ + Resource: &authzV2.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: "https://reg_res/resource1/value/value1", + }, + } + tests := []struct { - name string - entitlements map[string][]*policy.Action - action *policy.Action - resource *authzV2.Resource - wantErr error + name string + entitlements map[string][]*policy.Action + action *policy.Action + resource *authzV2.Resource + namespacedPolicy bool + wantErr error }{ { - name: "Valid inputs", - entitlements: validEntitledFQNsToActions, - action: validAction, - resource: validResource, - wantErr: nil, + name: "Valid inputs", + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: validResource, + namespacedPolicy: false, + wantErr: nil, + }, + { + name: "Nil entitlements", + entitlements: nil, + action: validAction, + resource: validResource, + namespacedPolicy: false, + wantErr: ErrInvalidEntitledFQNsToActions, }, { - name: "Nil entitlements", - entitlements: nil, - action: validAction, - resource: validResource, - wantErr: ErrInvalidEntitledFQNsToActions, + name: "Nil action", + entitlements: validEntitledFQNsToActions, + action: nil, + resource: validResource, + namespacedPolicy: false, + wantErr: ErrInvalidAction, }, { - name: "Nil action", - entitlements: validEntitledFQNsToActions, - action: nil, - resource: validResource, - wantErr: ErrInvalidAction, + name: "Nil resource", + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: nil, + namespacedPolicy: false, + wantErr: ErrInvalidResource, }, { - name: "Nil resource", - entitlements: validEntitledFQNsToActions, - action: validAction, - resource: nil, - wantErr: ErrInvalidResource, + name: "Empty action", + entitlements: validEntitledFQNsToActions, + action: &policy.Action{}, + resource: validResource, + namespacedPolicy: false, + wantErr: ErrInvalidAction, }, { - name: "Empty action", - entitlements: validEntitledFQNsToActions, - action: &policy.Action{}, - resource: validResource, - wantErr: ErrInvalidAction, + name: "Empty resource", + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: &authzV2.Resource{}, + namespacedPolicy: false, + wantErr: ErrInvalidResource, }, { - name: "Empty resource", - entitlements: validEntitledFQNsToActions, - action: validAction, - resource: &authzV2.Resource{}, - wantErr: ErrInvalidResource, + name: "Unnamespaced RR resource", + entitlements: validEntitledFQNsToActions, + action: validAction, + resource: validRRResource, + namespacedPolicy: true, + wantErr: ErrInvalidResource, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateGetResourceDecision(tt.entitlements, tt.action, tt.resource) + err := validateGetResourceDecision(tt.entitlements, tt.action, tt.resource, tt.namespacedPolicy) if tt.wantErr != nil { require.ErrorIs(t, err, tt.wantErr) } else { @@ -486,6 +507,7 @@ func TestValidateGetResourceDecision(t *testing.T) { func TestValidateGetDecisionRegisteredResource(t *testing.T) { validRegisteredResourceValueFQN := "https://reg_res/resource1/value/value1" + validNamespacedRegisteredResourceValueFQN := "https://namespace.com/reg_res/resource1/value/value1" validAction := &policy.Action{ Name: "read", @@ -510,6 +532,7 @@ func TestValidateGetDecisionRegisteredResource(t *testing.T) { registeredResourceValueFQN string action *policy.Action resources []*authzV2.Resource + namespacedPolicy bool wantErr error }{ { @@ -517,6 +540,7 @@ func TestValidateGetDecisionRegisteredResource(t *testing.T) { registeredResourceValueFQN: validRegisteredResourceValueFQN, action: validAction, resources: validResources, + namespacedPolicy: false, wantErr: nil, }, { @@ -524,6 +548,7 @@ func TestValidateGetDecisionRegisteredResource(t *testing.T) { registeredResourceValueFQN: "invalid-fqn", action: validAction, resources: validResources, + namespacedPolicy: false, wantErr: identifier.ErrInvalidFQNFormat, }, { @@ -531,6 +556,7 @@ func TestValidateGetDecisionRegisteredResource(t *testing.T) { registeredResourceValueFQN: validRegisteredResourceValueFQN, action: emptyNameAction, resources: validResources, + namespacedPolicy: false, wantErr: ErrInvalidAction, }, { @@ -538,6 +564,7 @@ func TestValidateGetDecisionRegisteredResource(t *testing.T) { registeredResourceValueFQN: validRegisteredResourceValueFQN, action: validAction, resources: []*authzV2.Resource{}, + namespacedPolicy: false, wantErr: ErrInvalidResource, }, { @@ -545,13 +572,30 @@ func TestValidateGetDecisionRegisteredResource(t *testing.T) { registeredResourceValueFQN: validRegisteredResourceValueFQN, action: validAction, resources: []*authzV2.Resource{nil}, + namespacedPolicy: false, + wantErr: ErrInvalidResource, + }, + { + name: "Unnamespaced RR resource in strict mode", + registeredResourceValueFQN: validRegisteredResourceValueFQN, + action: validAction, + resources: validResources, + namespacedPolicy: true, wantErr: ErrInvalidResource, }, + { + name: "Namespaced RR resource in strict mode pass", + registeredResourceValueFQN: validNamespacedRegisteredResourceValueFQN, + action: validAction, + resources: validResources, + namespacedPolicy: true, + wantErr: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateGetDecisionRegisteredResource(tt.registeredResourceValueFQN, tt.action, tt.resources) + err := validateGetDecisionRegisteredResource(tt.registeredResourceValueFQN, tt.action, tt.resources, tt.namespacedPolicy) if tt.wantErr != nil { require.ErrorIs(t, err, tt.wantErr) } else { From f026dbedb79545f560587a4fd123312c85c48145 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 15 Apr 2026 15:29:27 -0400 Subject: [PATCH 36/39] extend integration test --- service/integration/registered_resources_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/integration/registered_resources_test.go b/service/integration/registered_resources_test.go index 16292fa96f..0b301700ea 100644 --- a/service/integration/registered_resources_test.go +++ b/service/integration/registered_resources_test.go @@ -358,6 +358,8 @@ func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_RegResValuesCont for _, aav := range actionAttrValues { s.NotNil(aav.GetAction()) s.NotNil(aav.GetAttributeValue()) + s.NotNil(aav.GetAction().GetNamespace(), "action namespace should be populated for namespaced RR") + s.Equal("example.com", aav.GetAction().GetNamespace().GetName()) } } if v.GetId() == val2.GetId() { @@ -367,6 +369,8 @@ func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_RegResValuesCont for _, aav := range actionAttrValues { s.NotNil(aav.GetAction()) s.NotNil(aav.GetAttributeValue()) + s.NotNil(aav.GetAction().GetNamespace(), "action namespace should be populated for namespaced RR") + s.Equal("example.com", aav.GetAction().GetNamespace().GetName()) } } } From 7612d4e5ecb51ebbe9c062f0c545283723bdb5f6 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 15 Apr 2026 15:35:59 -0400 Subject: [PATCH 37/39] linting --- service/internal/access/v2/pdp.go | 2 +- service/internal/access/v2/validators.go | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 63cae6031c..784ea6988e 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -288,7 +288,7 @@ func (p *PolicyDecisionPoint) GetDecision( // same value FQN instead of replacing them. actions, ok := entitledFQNsToActions[fqn] if !ok { - actions = make([]*policy.Action, len(actionNames)) + actions = make([]*policy.Action, 0, len(actionNames)) } for _, name := range actionNames { actions = append(actions, &policy.Action{ diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index f4e10dd345..fc492d33ed 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/opentdf/platform/lib/identifier" - authz "github.com/opentdf/platform/protocol/go/authorization/v2" 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" @@ -197,12 +196,10 @@ func validateGetResourceDecision( return fmt.Errorf("resource is nil: %w", ErrInvalidResource) } if namespacedPolicy { - switch resource.GetResource().(type) { - case *authz.Resource_RegisteredResourceValueFqn: + if _, ok := resource.GetResource().(*authzV2.Resource_RegisteredResourceValueFqn); ok { registeredResourceValueFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) - // If namespaced policies are enabled, enforce that the registered resource value FQN is namespaced and extract the required namespace for later checks + // If namespaced policies are enabled, enforce that the registered resource value FQN is namespaced. parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) - if err != nil { return fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) } From c89d3e468acdd3b8e9e1f9bd3ef264c6b6d7b32c Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 15 Apr 2026 15:42:02 -0400 Subject: [PATCH 38/39] lint --- service/internal/access/v2/validators.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index fc492d33ed..65c12f21c2 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -60,7 +60,7 @@ func validateGetDecisionRegisteredResource(registeredResourceValueFQN string, ac return fmt.Errorf("resource is nil: %w", ErrInvalidResource) } } - if namespacedPolicy { + if namespacedPolicy { //nolint:nestif // validation reads clearer inline parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) if err != nil { return fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) From 6eb73e057e5d5444adca2957802e55e1f0b22111 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Wed, 15 Apr 2026 15:49:59 -0400 Subject: [PATCH 39/39] lint --- service/internal/access/v2/validators.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index 65c12f21c2..fff3d6e451 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -60,7 +60,7 @@ func validateGetDecisionRegisteredResource(registeredResourceValueFQN string, ac return fmt.Errorf("resource is nil: %w", ErrInvalidResource) } } - if namespacedPolicy { //nolint:nestif // validation reads clearer inline + if namespacedPolicy { parsed, err := identifier.Parse[*identifier.FullyQualifiedRegisteredResourceValue](registeredResourceValueFQN) if err != nil { return fmt.Errorf("invalid registered resource value FQN [%s]: %w", registeredResourceValueFQN, ErrInvalidResource) @@ -195,7 +195,7 @@ func validateGetResourceDecision( if resource.GetResource() == nil { return fmt.Errorf("resource is nil: %w", ErrInvalidResource) } - if namespacedPolicy { + if namespacedPolicy { //nolint:nestif // validation reads clearer inline if _, ok := resource.GetResource().(*authzV2.Resource_RegisteredResourceValueFqn); ok { registeredResourceValueFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) // If namespaced policies are enabled, enforce that the registered resource value FQN is namespaced.