diff --git a/service/authorization/v2/cache.go b/service/authorization/v2/cache.go index 64a1140e15..8979a32dec 100644 --- a/service/authorization/v2/cache.go +++ b/service/authorization/v2/cache.go @@ -41,7 +41,10 @@ var ( ErrCachedTypeNotExpected = errors.New("cached data is not of expected type") ) -// EntitlementPolicyCache caches attributes and subject mappings with periodic refresh +// EntitlementPolicyCache caches attributes, subject mappings, registered resources, obligations, and +// (when enabled) dynamic value mappings with periodic refresh. The default decision path fetches +// attributes and subject mappings per request via GetEntitleableAttributesByFqns; the cached copies +// back the full-policy fallback used when direct entitlements or dynamic value mappings are enabled. type EntitlementPolicyCache struct { logger *logger.Logger cacheClient *cache.Cache @@ -177,7 +180,9 @@ func (c *EntitlementPolicyCache) Stop() { // Refresh manually refreshes the cache by reaching out to policy services. In the event of an error, // the cache is marked as not filled, and the error is returned. func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { - // Retrieve fresh data from the policy services + // Retrieve fresh data from the policy services. Attributes and subject mappings are cached so the + // full-policy fallback (direct entitlements / dynamic value mappings) can read them from the cache + // rather than re-scanning both endpoints on every request. attributes, err := c.retriever.ListAllAttributes(ctx) if err != nil { return err diff --git a/service/internal/access/v2/entitleable.go b/service/internal/access/v2/entitleable.go new file mode 100644 index 0000000000..e8dbc79ee7 --- /dev/null +++ b/service/internal/access/v2/entitleable.go @@ -0,0 +1,191 @@ +package access + +import ( + "context" + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + otdfSDK "github.com/opentdf/platform/sdk" +) + +// maxEntitleableFQNsPerRequest mirrors the proto max_items limit on +// GetEntitleableAttributesByFqnsRequest.fqns, so batches never exceed what the +// server-side request validation accepts. +const maxEntitleableFQNsPerRequest = 250 + +// fetchEntitleableAttributes performs targeted GetEntitleableAttributesByFqns lookups for the +// provided attribute value FQNs and returns attribute definitions (with their values populated) plus +// the subject mappings that entitle them, in the shape NewPolicyDecisionPoint consumes. +// +// It mirrors the v1 authorization service's retrieveAttributeDefinitions +// (service/authorization/authorization.go); keep the two in sync. The v1 version returns a +// map[valueFQN]*AttributeAndValue for OPA input, whereas the v2 PDP is built from +// []*policy.Attribute + []*policy.SubjectMapping, so this emits those two slices instead. +// +// Subject mappings are carried ONLY in the returned slice, never on definition.Values[*]. +// SubjectMappings: NewPolicyDecisionPoint seeds each value from the definition keeping its +// SubjectMappings and then appends the slice's mappings, so populating both would double-count. +// +// Definitions are registered even when a requested value resolves with an empty identity (a value +// that does not exist under an allow_traversal definition), so the decision path can still +// synthesize direct-entitlement / dynamic-mapping values from the definition. Missing FQNs are +// omitted (not an error): the v2 decision path denies per-resource on unknown FQNs. Empty but +// non-nil slices are returned when nothing resolves, since NewPolicyDecisionPoint rejects nil inputs. +func fetchEntitleableAttributes( + ctx context.Context, + sdk *otdfSDK.SDK, + valueFQNs []string, +) ([]*policy.Attribute, []*policy.SubjectMapping, error) { + // Normalize + dedupe requested value FQNs to lower case. + normalizedFQNs := make([]string, 0, len(valueFQNs)) + seen := make(map[string]struct{}, len(valueFQNs)) + for _, fqn := range valueFQNs { + normalized := strings.ToLower(fqn) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + normalizedFQNs = append(normalizedFQNs, normalized) + } + + definitionsByFQN := make(map[string]*policy.Attribute) + // Per-definition set of value FQNs already appended, to dedupe across requested values and batches. + valuesSeenByDefinition := make(map[string]map[string]struct{}) + // Hierarchy definitions are expanded (ordered siblings + their SMs) exactly once. + hierarchyExpanded := make(map[string]struct{}) + subjectMappings := make([]*policy.SubjectMapping, 0) + + ensureDefinition := func(definitionFQN string, def *attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition) *policy.Attribute { + if existing, ok := definitionsByFQN[definitionFQN]; ok { + return existing + } + attribute := &policy.Attribute{ + Fqn: definitionFQN, + Namespace: def.GetNamespace(), + Rule: def.GetRule(), + } + definitionsByFQN[definitionFQN] = attribute + valuesSeenByDefinition[definitionFQN] = make(map[string]struct{}) + return attribute + } + + addValue := func(definitionFQN string, attribute *policy.Attribute, valueID, valueFQN string) { + valuesSeen := valuesSeenByDefinition[definitionFQN] + if _, ok := valuesSeen[valueFQN]; ok { + return + } + valuesSeen[valueFQN] = struct{}{} + attribute.Values = append(attribute.Values, &policy.Value{ + Id: valueID, + Fqn: valueFQN, + }) + } + + // process maps one entitleable response for the requested FQNs into the accumulators above. + process := func(resp *attrs.GetEntitleableAttributesByFqnsResponse, fqns []string) error { + for _, fqn := range fqns { + entitleable, ok := resp.GetFqnEntitleableAttributes()[fqn] + if !ok { + // FQN not returned at all: omit so the decision path denies per-resource. + continue + } + + definitionFQN := entitleable.GetDefinitionFqn() + def, defOK := resp.GetDefinitions()[definitionFQN] + if definitionFQN == "" || !defOK || def == nil { + return fmt.Errorf("entitleable attribute %q references missing definition %q", fqn, definitionFQN) + } + // Register the definition regardless of value presence so direct-entitlement / dynamic + // synthesis can resolve the parent definition for allow_traversal values. + attribute := ensureDefinition(definitionFQN, def) + + if def.GetRule() == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + // Hierarchy rule evaluation and comprehensive-hierarchy expansion need the full + // ordered value set and each value's subject mappings. Expand once per definition. + if _, done := hierarchyExpanded[definitionFQN]; !done { + hierarchyExpanded[definitionFQN] = struct{}{} + for _, sibling := range def.GetValues() { + if sibling.GetValueId() == "" { + continue + } + addValue(definitionFQN, attribute, sibling.GetValueId(), sibling.GetFqn()) + subjectMappings = append(subjectMappings, sibling.GetSubjectMappings()...) + } + } + continue + } + + // Non-hierarchy: add the concrete value when present. An empty value identity is an + // allow_traversal miss; the definition stays registered but no concrete value is added. + value := entitleable.GetValue() + if value.GetValueId() == "" { + continue + } + addValue(definitionFQN, attribute, value.GetValueId(), value.GetFqn()) + subjectMappings = append(subjectMappings, value.GetSubjectMappings()...) + } + return nil + } + + getBatch := func(fqns []string) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return sdk.Attributes.GetEntitleableAttributesByFqns(ctx, &attrs.GetEntitleableAttributesByFqnsRequest{Fqns: fqns}) + } + + // fetchOne resolves a single FQN, reporting skip=true when it does not exist in policy. + fetchOne := func(fqn string) (*attrs.GetEntitleableAttributesByFqnsResponse, bool, error) { + resp, err := getBatch([]string{fqn}) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return nil, true, nil + } + return nil, false, fmt.Errorf("failed to get entitleable attributes by fqns: %w", err) + } + return resp, false, nil + } + + // processBatch resolves a batch, falling back to per-FQN resolution on a batch NotFound. The + // server rejects the whole batch with NotFound if any requested FQN does not exist, so the retry + // keeps the values that DO exist and skips the missing ones (denied per-resource downstream). This + // preserves valid decisions in a multi-resource request that also references an unknown FQN. + processBatch := func(batch []string) error { + resp, err := getBatch(batch) + if err == nil { + return process(resp, batch) + } + if connect.CodeOf(err) != connect.CodeNotFound { + return fmt.Errorf("failed to get entitleable attributes by fqns: %w", err) + } + for _, fqn := range batch { + single, skip, ferr := fetchOne(fqn) + if ferr != nil { + return ferr + } + if skip { + continue + } + if perr := process(single, []string{fqn}); perr != nil { + return perr + } + } + return nil + } + + for start := 0; start < len(normalizedFQNs); start += maxEntitleableFQNsPerRequest { + end := min(start+maxEntitleableFQNsPerRequest, len(normalizedFQNs)) + if err := processBatch(normalizedFQNs[start:end]); err != nil { + return nil, nil, err + } + } + + definitions := make([]*policy.Attribute, 0, len(definitionsByFQN)) + for _, def := range definitionsByFQN { + definitions = append(definitions, def) + } + return definitions, subjectMappings, nil +} diff --git a/service/internal/access/v2/entitleable_test.go b/service/internal/access/v2/entitleable_test.go new file mode 100644 index 0000000000..c1d7e539e2 --- /dev/null +++ b/service/internal/access/v2/entitleable_test.go @@ -0,0 +1,223 @@ +package access + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/sdk/sdkconnect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + otdfSDK "github.com/opentdf/platform/sdk" +) + +// fakeAttributesClient embeds the interface (nil) so it satisfies AttributesServiceClient while only +// overriding GetEntitleableAttributesByFqns. Any other method call would panic, which is fine for +// tests that never invoke them. +type fakeAttributesClient struct { + sdkconnect.AttributesServiceClient + respFunc func(req *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) + requests []*attrs.GetEntitleableAttributesByFqnsRequest +} + +func (f *fakeAttributesClient) GetEntitleableAttributesByFqns(_ context.Context, req *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + f.requests = append(f.requests, req) + return f.respFunc(req) +} + +func newSDKWithAttributes(f *fakeAttributesClient) *otdfSDK.SDK { + return &otdfSDK.SDK{Attributes: f} +} + +func TestFetchEntitleableAttributes_MapsAndDedupes(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/confidential" + sm := &policy.SubjectMapping{Id: "sm-1", AttributeValue: &policy.Value{Fqn: valueFQN}} + + fake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: { + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Namespace: &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"}, + }, + }, + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + valueFQN: { + DefinitionFqn: definitionFQN, + Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{ + Fqn: valueFQN, ValueId: "confidential-id", SubjectMappings: []*policy.SubjectMapping{sm}, + }, + }, + }, + }, nil + }, + } + + // Uppercase + duplicate inputs should be normalized and deduped to a single requested FQN. + defs, sms, err := fetchEntitleableAttributes(context.Background(), newSDKWithAttributes(fake), []string{strings.ToUpper(valueFQN), valueFQN}) + require.NoError(t, err) + require.Len(t, fake.requests, 1) + assert.Equal(t, []string{valueFQN}, fake.requests[0].GetFqns()) + + require.Len(t, defs, 1) + assert.Equal(t, definitionFQN, defs[0].GetFqn()) + assert.Equal(t, policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, defs[0].GetRule()) + assert.Equal(t, "ns-1", defs[0].GetNamespace().GetId()) + require.Len(t, defs[0].GetValues(), 1) + assert.Equal(t, valueFQN, defs[0].GetValues()[0].GetFqn()) + assert.Equal(t, "confidential-id", defs[0].GetValues()[0].GetId()) + // SMs are carried only in the returned slice, never on the definition's values (no double-count). + assert.Empty(t, defs[0].GetValues()[0].GetSubjectMappings()) + require.Len(t, sms, 1) + assert.Equal(t, "sm-1", sms[0].GetId()) +} + +func TestFetchEntitleableAttributes_Batches(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + fake := &fakeAttributesClient{ + respFunc: func(req *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + resp := &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: {Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF}, + }, + FqnEntitleableAttributes: make(map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute), + } + for _, fqn := range req.GetFqns() { + resp.FqnEntitleableAttributes[fqn] = &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + DefinitionFqn: definitionFQN, + Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{Fqn: fqn, ValueId: fqn + "-id"}, + } + } + return resp, nil + }, + } + + fqns := make([]string, maxEntitleableFQNsPerRequest+1) + for i := range fqns { + fqns[i] = fmt.Sprintf("%s/value/value-%03d", definitionFQN, i) + } + + defs, _, err := fetchEntitleableAttributes(context.Background(), newSDKWithAttributes(fake), fqns) + require.NoError(t, err) + require.Len(t, fake.requests, 2) + assert.Len(t, fake.requests[0].GetFqns(), maxEntitleableFQNsPerRequest) + assert.Len(t, fake.requests[1].GetFqns(), 1) + // All values map to the one definition. + require.Len(t, defs, 1) + assert.Len(t, defs[0].GetValues(), len(fqns)) +} + +func TestFetchEntitleableAttributes_Hierarchy(t *testing.T) { + definitionFQN := "https://example.com/attr/clearance" + high := definitionFQN + "/value/high" + mid := definitionFQN + "/value/mid" + low := definitionFQN + "/value/low" + smHigh := &policy.SubjectMapping{Id: "sm-high", AttributeValue: &policy.Value{Fqn: high}} + + fake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: { + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Values: []*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{ + {Fqn: high, ValueId: "high-id", SubjectMappings: []*policy.SubjectMapping{smHigh}}, + {Fqn: mid, ValueId: "mid-id"}, + {Fqn: low, ValueId: "low-id"}, + }, + }, + }, + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + high: { + DefinitionFqn: definitionFQN, + Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{Fqn: high, ValueId: "high-id", SubjectMappings: []*policy.SubjectMapping{smHigh}}, + }, + }, + }, nil + }, + } + + defs, sms, err := fetchEntitleableAttributes(context.Background(), newSDKWithAttributes(fake), []string{high}) + require.NoError(t, err) + require.Len(t, defs, 1) + // Ordered sibling values are populated for hierarchy definitions. + valueFQNs := make([]string, 0, len(defs[0].GetValues())) + for _, v := range defs[0].GetValues() { + valueFQNs = append(valueFQNs, v.GetFqn()) + } + assert.Equal(t, []string{high, mid, low}, valueFQNs) + // The requested value's subject mapping is counted exactly once (from the sibling expansion). + require.Len(t, sms, 1) + assert.Equal(t, "sm-high", sms[0].GetId()) +} + +func TestFetchEntitleableAttributes_MissingFqnOmitted(t *testing.T) { + fake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{}, + }, nil + }, + } + + defs, sms, err := fetchEntitleableAttributes(context.Background(), newSDKWithAttributes(fake), []string{"https://example.com/attr/classification/value/missing"}) + require.NoError(t, err) + assert.NotNil(t, defs) + assert.NotNil(t, sms) + assert.Empty(t, defs) + assert.Empty(t, sms) +} + +func TestFetchEntitleableAttributes_MissingDefinitionErrors(t *testing.T) { + valueFQN := "https://example.com/attr/classification/value/confidential" + fake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + valueFQN: { + DefinitionFqn: "https://example.com/attr/classification", + Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{Fqn: valueFQN, ValueId: "id"}, + }, + }, + }, nil + }, + } + + defs, sms, err := fetchEntitleableAttributes(context.Background(), newSDKWithAttributes(fake), []string{valueFQN}) + require.Error(t, err) + assert.Nil(t, defs) + assert.Nil(t, sms) + assert.Contains(t, err.Error(), "references missing definition") +} + +func TestFetchEntitleableAttributes_AllowTraversalEmptyValueRegistersDefinition(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/adhoc" + fake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: {Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF}, + }, + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + // allow_traversal miss: value returned with empty identity. + valueFQN: {DefinitionFqn: definitionFQN, Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{}}, + }, + }, nil + }, + } + + defs, sms, err := fetchEntitleableAttributes(context.Background(), newSDKWithAttributes(fake), []string{valueFQN}) + require.NoError(t, err) + // Definition is registered (for direct-entitlement synthesis) but carries no concrete value. + require.Len(t, defs, 1) + assert.Equal(t, definitionFQN, defs[0].GetFqn()) + assert.Empty(t, defs[0].GetValues()) + assert.Empty(t, sms) +} diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 9a22888589..5de87648a2 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -13,6 +13,7 @@ import ( "github.com/opentdf/platform/protocol/go/entity" entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" "github.com/opentdf/platform/protocol/go/policy/subjectmapping" otdfSDK "github.com/opentdf/platform/sdk" ent "github.com/opentdf/platform/service/entity" @@ -40,10 +41,23 @@ var ( type JustInTimePDP struct { logger *logger.Logger sdk *otdfSDK.SDK - // embedded entitlement PDP - pdp *PolicyDecisionPoint // embedded obligations PDP obligationsPDP *obligations.ObligationsPolicyDecisionPoint + + // fullPolicyPDP is non-nil only when direct entitlements or dynamic value mappings are enabled. + // Those features entitle values that may not exist in policy, which targeted lookups cannot + // supply, so the PDP is built once from the full policy load and reused for every request. + fullPolicyPDP *PolicyDecisionPoint + + // Registered resources, obligations, and (gated) dynamic value mappings remain fully loaded at + // construction; attribute definitions and subject mappings are fetched per request via + // GetEntitleableAttributesByFqns and used to build a request-scoped inner PolicyDecisionPoint. + registeredResources []*policy.RegisteredResource + registeredResourceValuesByFQN map[string]*policy.RegisteredResourceValue + dynamicValueMappings []*policy.DynamicValueMapping + allowDirectEntitlements bool + allowDynamicValueMappings bool + namespacedPolicy bool } // NewJustInTimePDP creates a new Policy Decision Point instance with no in-memory policy and a remote connection @@ -70,8 +84,11 @@ func NewJustInTimePDP( } p := &JustInTimePDP{ - sdk: sdk, - logger: log, + sdk: sdk, + logger: log, + allowDirectEntitlements: allowDirectEntitlements, + allowDynamicValueMappings: allowDynamicValueMappings, + namespacedPolicy: namespacedPolicy, } // If no store is provided, have EntitlementPolicyRetriever fetch from policy services @@ -80,14 +97,9 @@ func NewJustInTimePDP( store = NewEntitlementPolicyRetriever(sdk) } - allAttributes, err := store.ListAllAttributes(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list cached attributes: %w", err) - } - allSubjectMappings, err := store.ListAllSubjectMappings(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list cached subject mappings: %w", err) - } + // Attributes and subject mappings are fetched per request (targeted), so they are no longer + // loaded here. Registered resources, obligations, and (gated) dynamic value mappings remain + // fully loaded because they are not covered by GetEntitleableAttributesByFqns. allRegisteredResources, err := store.ListAllRegisteredResources(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch all registered resources: %w", err) @@ -97,25 +109,28 @@ func NewJustInTimePDP( return nil, fmt.Errorf("failed to fetch all obligations: %w", err) } // Experimental: only load dynamic value mappings when the feature is enabled. - var allDynamicValueMappings []*policy.DynamicValueMapping if allowDynamicValueMappings { - allDynamicValueMappings, err = store.ListAllDynamicValueMappings(ctx) + p.dynamicValueMappings, err = store.ListAllDynamicValueMappings(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch all dynamic value mappings: %w", err) } } + p.registeredResources = allRegisteredResources - pdp, err := NewPolicyDecisionPoint(ctx, log, allAttributes, allSubjectMappings, allRegisteredResources, allowDirectEntitlements, namespacedPolicy, WithDynamicValueMappings(allDynamicValueMappings, allowDynamicValueMappings)) + registeredResourceValuesByFQN, err := buildRegisteredResourceValuesByFQN(allRegisteredResources, namespacedPolicy) if err != nil { - return nil, fmt.Errorf("failed to create new policy decision point: %w", err) + return nil, fmt.Errorf("failed to index registered resources: %w", err) } - p.pdp = pdp + p.registeredResourceValuesByFQN = registeredResourceValuesByFQN + // Obligations are triggered by (action, attribute value FQN, PEP client) against a trigger graph + // built from all obligations; the attributes-by-value map is unused by the obligations PDP, so an + // empty map is passed. obligationsPDP, err := obligations.NewObligationsPolicyDecisionPoint( ctx, log, - pdp.allEntitleableAttributesByValueFQN, - pdp.allRegisteredResourceValuesByFQN, + make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + registeredResourceValuesByFQN, allObligations, ) if err != nil { @@ -123,6 +138,38 @@ func NewJustInTimePDP( } p.obligationsPDP = obligationsPDP + // Direct entitlements and dynamic value mappings entitle attribute values that may not exist in + // policy; synthesizing them requires the full definition set, which targeted + // GetEntitleableAttributesByFqns lookups cannot supply (a non-existent value FQN errors). When + // either experimental feature is enabled, build the PDP from the full policy load instead. + if allowDirectEntitlements || allowDynamicValueMappings { + // Read attributes and subject mappings from the same store used above (the refresh cache when + // ready, otherwise the live retriever), so a cache-enabled deployment does not re-scan both + // policy endpoints on every request. + allAttributes, err := store.ListAllAttributes(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list attributes: %w", err) + } + allSubjectMappings, err := store.ListAllSubjectMappings(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list subject mappings: %w", err) + } + fullPolicyPDP, err := NewPolicyDecisionPoint( + ctx, + log, + allAttributes, + allSubjectMappings, + allRegisteredResources, + allowDirectEntitlements, + namespacedPolicy, + WithDynamicValueMappings(p.dynamicValueMappings, allowDynamicValueMappings), + ) + if err != nil { + return nil, fmt.Errorf("failed to create full-policy decision point: %w", err) + } + p.fullPolicyPDP = fullPolicyPDP + } + return p, nil } @@ -187,7 +234,11 @@ func (p *JustInTimePDP) GetDecision( case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: regResValueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) // Registered resources do not have entity representations, so only one decision is made - decision, entitlements, err := p.pdp.GetDecisionRegisteredResource(ctx, regResValueFQN, action, resources) + innerPDP, err := p.buildInnerPDP(ctx, p.resourceValueFQNs(resources)) + if err != nil { + return nil, fmt.Errorf("failed to build policy decision point: %w", err) + } + decision, entitlements, err := innerPDP.GetDecisionRegisteredResource(ctx, regResValueFQN, action, resources) if err != nil { return nil, fmt.Errorf("failed to get decision for registered resource value FQN [%s]: %w", regResValueFQN, err) } @@ -228,8 +279,14 @@ func (p *JustInTimePDP) GetDecision( var resourceDecisionsAcrossAllEntityReps []ResourceDecision allPermitted := true + // Build a request-scoped PDP from only the attributes needed to decide these resources. + innerPDP, err := p.buildInnerPDP(ctx, p.resourceValueFQNs(resources)) + if err != nil { + return nil, fmt.Errorf("failed to build policy decision point: %w", err) + } + for _, entityRep := range entityRepresentations { - entityRepresentationDecision, entitlements, err := p.pdp.GetDecision(ctx, entityRep, action, resources) + entityRepresentationDecision, entitlements, err := innerPDP.GetDecision(ctx, entityRep, action, resources) if err != nil { return nil, fmt.Errorf("failed to get decision for entityRepresentation with original id [%s]: %w", entityRep.GetOriginalId(), err) } @@ -301,7 +358,11 @@ func (p *JustInTimePDP) GetEntitlements( p.logger.DebugContext(ctx, "getting entitlements - resolving registered resource value FQN") regResValueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) // registered resources do not have entity representations, so we can skip the remaining logic - return p.pdp.GetEntitlementsRegisteredResource(ctx, regResValueFQN, withComprehensiveHierarchy) + innerPDP, err := p.buildInnerPDP(ctx, p.registeredResourceActionAttributeValueFQNs(regResValueFQN)) + if err != nil { + return nil, fmt.Errorf("failed to build policy decision point: %w", err) + } + return innerPDP.GetEntitlementsRegisteredResource(ctx, regResValueFQN, withComprehensiveHierarchy) case *authzV2.EntityIdentifier_WithRequestToken: entityRepresentations, err = p.resolveEntitiesFromRequestToken(ctx, entityIdentifier.GetWithRequestToken(), skipEnvironmentEntities, []*authzV2.Resource{}) @@ -323,13 +384,96 @@ func (p *JustInTimePDP) GetEntitlements( return nil, nil } - entitlements, err := p.pdp.GetEntitlements(ctx, entityRepresentations, matchedSubjectMappings, withComprehensiveHierarchy) + // Build a request-scoped PDP from only the attributes referenced by the matched subject mappings. + innerPDP, err := p.buildInnerPDP(ctx, valueFQNsFromSubjectMappings(matchedSubjectMappings)) + if err != nil { + return nil, fmt.Errorf("failed to build policy decision point: %w", err) + } + + entitlements, err := innerPDP.GetEntitlements(ctx, entityRepresentations, matchedSubjectMappings, withComprehensiveHierarchy) if err != nil { return nil, fmt.Errorf("failed to get entitlements: %w", err) } return entitlements, nil } +// buildInnerPDP fetches the entitleable attributes for the provided value FQNs and constructs a +// request-scoped PolicyDecisionPoint from them plus the fully-loaded registered resources and +// dynamic value mappings. +func (p *JustInTimePDP) buildInnerPDP(ctx context.Context, valueFQNs []string) (*PolicyDecisionPoint, error) { + // Direct entitlements / dynamic value mappings require the full policy load (see NewJustInTimePDP). + if p.fullPolicyPDP != nil { + return p.fullPolicyPDP, nil + } + // fetchEntitleableAttributes omits value FQNs that do not exist in policy (retrying per FQN on a + // batch NotFound), so unknown FQNs are absent from the returned definitions and get denied + // per-resource downstream rather than failing the whole request. + definitions, subjectMappings, err := fetchEntitleableAttributes(ctx, p.sdk, valueFQNs) + if err != nil { + return nil, fmt.Errorf("failed to fetch entitleable attributes: %w", err) + } + pdp, err := NewPolicyDecisionPoint( + ctx, + p.logger, + definitions, + subjectMappings, + p.registeredResources, + p.allowDirectEntitlements, + p.namespacedPolicy, + WithDynamicValueMappings(p.dynamicValueMappings, p.allowDynamicValueMappings), + ) + if err != nil { + return nil, fmt.Errorf("failed to create request-scoped policy decision point: %w", err) + } + return pdp, nil +} + +// resourceValueFQNs collects the lower-cased attribute value FQNs needed to decide the provided +// resources: attribute-value resources contribute their FQNs directly, and registered-resource +// resources contribute the attribute value FQNs of their action-attribute-values. +func (p *JustInTimePDP) resourceValueFQNs(resources []*authzV2.Resource) []string { + fqns := make([]string, 0, len(resources)) + for _, resource := range resources { + switch resource.GetResource().(type) { + case *authzV2.Resource_AttributeValues_: + for _, fqn := range resource.GetAttributeValues().GetFqns() { + fqns = append(fqns, strings.ToLower(fqn)) + } + case *authzV2.Resource_RegisteredResourceValueFqn: + regResValueFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) + fqns = append(fqns, p.registeredResourceActionAttributeValueFQNs(regResValueFQN)...) + } + } + return fqns +} + +// registeredResourceActionAttributeValueFQNs returns the lower-cased attribute value FQNs entitled +// by the action-attribute-values of the given registered resource value. +func (p *JustInTimePDP) registeredResourceActionAttributeValueFQNs(registeredResourceValueFQN string) []string { + rrValue, ok := p.registeredResourceValuesByFQN[registeredResourceValueFQN] + if !ok { + return nil + } + fqns := make([]string, 0, len(rrValue.GetActionAttributeValues())) + for _, aav := range rrValue.GetActionAttributeValues() { + fqns = append(fqns, strings.ToLower(aav.GetAttributeValue().GetFqn())) + } + return fqns +} + +// valueFQNsFromSubjectMappings collects the lower-cased attribute value FQNs referenced by the +// provided subject mappings. +func valueFQNsFromSubjectMappings(subjectMappings []*policy.SubjectMapping) []string { + fqns := make([]string, 0, len(subjectMappings)) + for _, sm := range subjectMappings { + fqn := strings.ToLower(sm.GetAttributeValue().GetFqn()) + if fqn != "" { + fqns = append(fqns, fqn) + } + } + return fqns +} + // getMatchedSubjectMappings retrieves the subject mappings for the provided entity representations func (p *JustInTimePDP) getMatchedSubjectMappings( ctx context.Context, diff --git a/service/internal/access/v2/just_in_time_pdp_targeted_test.go b/service/internal/access/v2/just_in_time_pdp_targeted_test.go new file mode 100644 index 0000000000..c09ccceda4 --- /dev/null +++ b/service/internal/access/v2/just_in_time_pdp_targeted_test.go @@ -0,0 +1,384 @@ +package access + +import ( + "context" + "errors" + "testing" + + "connectrpc.com/connect" + authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + otdfSDK "github.com/opentdf/platform/sdk" + "github.com/opentdf/platform/sdk/sdkconnect" + "github.com/opentdf/platform/service/internal/access/v2/obligations" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/logger/audit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// fakeSubjectMappingClient embeds the interface (nil) and only overrides MatchSubjectMappings. +type fakeSubjectMappingClient struct { + sdkconnect.SubjectMappingServiceClient + resp *subjectmapping.MatchSubjectMappingsResponse + err error + requests []*subjectmapping.MatchSubjectMappingsRequest +} + +func (f *fakeSubjectMappingClient) MatchSubjectMappings(_ context.Context, req *subjectmapping.MatchSubjectMappingsRequest) (*subjectmapping.MatchSubjectMappingsResponse, error) { + f.requests = append(f.requests, req) + return f.resp, f.err +} + +func clientIDInConditionSet(clientID string) *policy.SubjectConditionSet { + return &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{{ + ConditionGroups: []*policy.ConditionGroup{{ + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + Conditions: []*policy.Condition{{ + SubjectExternalSelectorValue: ".clientId", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{clientID}, + }}, + }}, + }}, + } +} + +func entityChainIdentifier() *authzV2.EntityIdentifier { + return &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{ + EntityChain: &entity.EntityChain{ + EphemeralId: "chain-1", + Entities: []*entity.Entity{{EphemeralId: "e1", Category: entity.Entity_CATEGORY_SUBJECT}}, + }, + }, + } +} + +func entityRepWithClientID(clientID string) *entityresolutionV2.EntityRepresentation { + props, _ := structpb.NewStruct(map[string]any{"clientId": clientID}) + return &entityresolutionV2.EntityRepresentation{ + OriginalId: "e1", + AdditionalProps: []*structpb.Struct{props}, + } +} + +func TestJITPDP_GetEntitlements_TargetedFetch(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/confidential" + + matchedSM := &policy.SubjectMapping{ + Id: "sm-1", + AttributeValue: &policy.Value{Fqn: valueFQN}, + SubjectConditionSet: clientIDInConditionSet("abc"), + Actions: []*policy.Action{{Name: "read"}}, + } + attrFake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: {Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF}, + }, + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + valueFQN: {DefinitionFqn: definitionFQN, Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{Fqn: valueFQN, ValueId: "conf-id"}}, + }, + }, nil + }, + } + smFake := &fakeSubjectMappingClient{resp: &subjectmapping.MatchSubjectMappingsResponse{SubjectMappings: []*policy.SubjectMapping{matchedSM}}} + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("abc")}, + }} + + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, SubjectMapping: smFake, EntityResolutionV2: ers}, + } + + ents, err := p.GetEntitlements(context.Background(), entityChainIdentifier(), false) + require.NoError(t, err) + require.Len(t, ents, 1) + assert.Equal(t, "e1", ents[0].GetEphemeralId()) + require.Contains(t, ents[0].GetActionsPerAttributeValueFqn(), valueFQN) + + // The entitleable fetch was targeted to only the matched value FQN. + require.Len(t, attrFake.requests, 1) + assert.Equal(t, []string{valueFQN}, attrFake.requests[0].GetFqns()) +} + +func TestJITPDP_GetEntitlements_NoMatchReturnsNil(t *testing.T) { + attrFake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{}, nil + }, + } + smFake := &fakeSubjectMappingClient{resp: &subjectmapping.MatchSubjectMappingsResponse{}} + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("abc")}, + }} + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, SubjectMapping: smFake, EntityResolutionV2: ers}, + } + + ents, err := p.GetEntitlements(context.Background(), entityChainIdentifier(), false) + require.NoError(t, err) + assert.Nil(t, ents) + // No match means no entitleable fetch is performed. + assert.Empty(t, attrFake.requests) +} + +func newTestObligationsPDP(t *testing.T) *obligations.ObligationsPolicyDecisionPoint { + t.Helper() + oPDP, err := obligations.NewObligationsPolicyDecisionPoint( + context.Background(), + logger.CreateTestLogger(), + make(map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue), + make(map[string]*policy.RegisteredResourceValue), + nil, + ) + require.NoError(t, err) + return oPDP +} + +func decisionAttrFake(definitionFQN, valueFQN, clientID string) *fakeAttributesClient { + sm := &policy.SubjectMapping{ + Id: "sm-1", + AttributeValue: &policy.Value{Fqn: valueFQN}, + SubjectConditionSet: clientIDInConditionSet(clientID), + Actions: []*policy.Action{{Name: "read"}}, + } + return &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: {Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF}, + }, + FqnEntitleableAttributes: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + valueFQN: { + DefinitionFqn: definitionFQN, + Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{ + Fqn: valueFQN, ValueId: "conf-id", SubjectMappings: []*policy.SubjectMapping{sm}, + }, + }, + }, + }, nil + }, + } +} + +func attrValueResource(valueFQN string) []*authzV2.Resource { + return []*authzV2.Resource{{ + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{Fqns: []string{valueFQN}}, + }, + }} +} + +func TestJITPDP_GetDecision_TargetedPermit(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/confidential" + + attrFake := decisionAttrFake(definitionFQN, valueFQN, "abc") + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("abc")}, + }} + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, EntityResolutionV2: ers}, + obligationsPDP: newTestObligationsPDP(t), + registeredResourceValuesByFQN: make(map[string]*policy.RegisteredResourceValue), + } + + ctx := audit.ContextWithActorID(context.Background(), "test-actor") + decision, err := p.GetDecision(ctx, entityChainIdentifier(), &policy.Action{Name: "read"}, attrValueResource(valueFQN), nil, nil) + require.NoError(t, err) + require.NotNil(t, decision) + assert.True(t, decision.AllPermitted) + + require.Len(t, attrFake.requests, 1) + assert.Equal(t, []string{valueFQN}, attrFake.requests[0].GetFqns()) +} + +func TestJITPDP_GetDecision_TargetedDenyOnEntityMismatch(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/confidential" + + // Subject mapping requires clientId "abc" but the entity presents "other". + attrFake := decisionAttrFake(definitionFQN, valueFQN, "abc") + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("other")}, + }} + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, EntityResolutionV2: ers}, + obligationsPDP: newTestObligationsPDP(t), + registeredResourceValuesByFQN: make(map[string]*policy.RegisteredResourceValue), + } + + ctx := audit.ContextWithActorID(context.Background(), "test-actor") + decision, err := p.GetDecision(ctx, entityChainIdentifier(), &policy.Action{Name: "read"}, attrValueResource(valueFQN), nil, nil) + require.NoError(t, err) + require.NotNil(t, decision) + assert.False(t, decision.AllPermitted) +} + +func TestJITPDP_GetDecision_NotFoundDegradesToDeny(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/finance" + + // The attributes service returns NotFound for the requested value (does not exist in policy). + attrFake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return nil, connect.NewError(connect.CodeNotFound, errors.New("not found")) + }, + } + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("abc")}, + }} + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, EntityResolutionV2: ers}, + obligationsPDP: newTestObligationsPDP(t), + registeredResourceValuesByFQN: make(map[string]*policy.RegisteredResourceValue), + } + + ctx := audit.ContextWithActorID(context.Background(), "test-actor") + decision, err := p.GetDecision(ctx, entityChainIdentifier(), &policy.Action{Name: "read"}, attrValueResource(valueFQN), nil, nil) + // A NotFound must degrade to a per-resource deny, not surface as an internal error. + require.NoError(t, err) + require.NotNil(t, decision) + assert.False(t, decision.AllPermitted) +} + +func TestJITPDP_GetDecision_MixedKnownUnknownFQNsPreservesKnown(t *testing.T) { + definitionFQN := "https://example.com/attr/department" + knownFQN := definitionFQN + "/value/eng" + unknownFQN := definitionFQN + "/value/finance" + + sm := &policy.SubjectMapping{ + Id: "sm-1", + AttributeValue: &policy.Value{Fqn: knownFQN}, + SubjectConditionSet: clientIDInConditionSet("abc"), + Actions: []*policy.Action{{Name: "read"}}, + } + // The server rejects any batch containing the unknown FQN with NotFound; a per-FQN retry resolves + // the known FQN and skips the unknown one. + attrFake := &fakeAttributesClient{ + respFunc: func(req *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + for _, f := range req.GetFqns() { + if f == unknownFQN { + return nil, connect.NewError(connect.CodeNotFound, errors.New("not found")) + } + } + resp := &attrs.GetEntitleableAttributesByFqnsResponse{ + Definitions: map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableDefinition{ + definitionFQN: {Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF}, + }, + FqnEntitleableAttributes: make(map[string]*attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute), + } + for _, f := range req.GetFqns() { + resp.FqnEntitleableAttributes[f] = &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{ + DefinitionFqn: definitionFQN, + Value: &attrs.GetEntitleableAttributesByFqnsResponse_EntitleableValue{Fqn: f, ValueId: f + "-id", SubjectMappings: []*policy.SubjectMapping{sm}}, + } + } + return resp, nil + }, + } + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("abc")}, + }} + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, EntityResolutionV2: ers}, + obligationsPDP: newTestObligationsPDP(t), + registeredResourceValuesByFQN: make(map[string]*policy.RegisteredResourceValue), + } + + resources := []*authzV2.Resource{ + {Resource: &authzV2.Resource_AttributeValues_{AttributeValues: &authzV2.Resource_AttributeValues{Fqns: []string{knownFQN}}}}, + {Resource: &authzV2.Resource_AttributeValues_{AttributeValues: &authzV2.Resource_AttributeValues{Fqns: []string{unknownFQN}}}}, + } + + ctx := audit.ContextWithActorID(context.Background(), "test-actor") + decision, err := p.GetDecision(ctx, entityChainIdentifier(), &policy.Action{Name: "read"}, resources, nil, nil) + require.NoError(t, err) + require.NotNil(t, decision) + require.Len(t, decision.Results, 2) + // The known resource is still decided (entitled); only the unknown one is denied. + assert.True(t, decision.Results[0].Entitled, "known resource should remain entitled") + assert.False(t, decision.Results[1].Entitled, "unknown resource should be denied") + assert.False(t, decision.AllPermitted) +} + +func TestJITPDP_buildInnerPDP_UsesFullPolicyPDPWhenSet(t *testing.T) { + // When the full-policy PDP is set (direct entitlements / dynamic value mappings mode), + // buildInnerPDP returns it without performing a targeted entitleable fetch. + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/confidential" + fullPDP, err := NewPolicyDecisionPoint( + context.Background(), + logger.CreateTestLogger(), + []*policy.Attribute{{ + Fqn: definitionFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Values: []*policy.Value{{Fqn: valueFQN}}, + }}, + []*policy.SubjectMapping{}, + nil, + true, + false, + ) + require.NoError(t, err) + + attrFake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + t.Fatal("targeted fetch should not be called when full-policy PDP is set") + return nil, errors.New("unreachable") + }, + } + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake}, + fullPolicyPDP: fullPDP, + } + + got, err := p.buildInnerPDP(context.Background(), []string{valueFQN}) + require.NoError(t, err) + assert.Same(t, fullPDP, got) + assert.Empty(t, attrFake.requests) +} + +func TestJITPDP_GetDecision_TargetedDenyOnUnknownFQN(t *testing.T) { + definitionFQN := "https://example.com/attr/classification" + valueFQN := definitionFQN + "/value/confidential" + + // The attributes service returns nothing for the requested FQN (unknown value). + attrFake := &fakeAttributesClient{ + respFunc: func(_ *attrs.GetEntitleableAttributesByFqnsRequest) (*attrs.GetEntitleableAttributesByFqnsResponse, error) { + return &attrs.GetEntitleableAttributesByFqnsResponse{}, nil + }, + } + ers := &recordingERSV2Client{resolveResponse: &entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("abc")}, + }} + p := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{Attributes: attrFake, EntityResolutionV2: ers}, + obligationsPDP: newTestObligationsPDP(t), + registeredResourceValuesByFQN: make(map[string]*policy.RegisteredResourceValue), + } + + ctx := audit.ContextWithActorID(context.Background(), "test-actor") + decision, err := p.GetDecision(ctx, entityChainIdentifier(), &policy.Action{Name: "read"}, attrValueResource(valueFQN), nil, nil) + require.NoError(t, err) + require.NotNil(t, decision) + assert.False(t, decision.AllPermitted) +} diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 530951be6f..5a1eeda521 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -218,35 +218,9 @@ func NewPolicyDecisionPoint( dynamicMappingsByDefinitionFQN[definitionFQN] = append(dynamicMappingsByDefinitionFQN[definitionFQN], mapping) } - allRegisteredResourceValuesByFQN := make(map[string]*policy.RegisteredResourceValue) - for _, rr := range allRegisteredResources { - if err := validateRegisteredResource(rr); err != nil { - return nil, fmt.Errorf("invalid registered resource: %w", err) - } - rrName := rr.GetName() - - for _, v := range rr.GetValues() { - if err := validateRegisteredResourceValue(v); err != nil { - 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 - - if !namespacedPolicy { - legacyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ - Name: rrName, - Value: v.GetValue(), - } - allRegisteredResourceValuesByFQN[legacyQualifiedValue.FQN()] = v - } - } + allRegisteredResourceValuesByFQN, err := buildRegisteredResourceValuesByFQN(allRegisteredResources, namespacedPolicy) + if err != nil { + return nil, err } pdp := &PolicyDecisionPoint{ @@ -283,6 +257,43 @@ func namespaceNameFromPolicyNamespace(ns *policy.Namespace) string { return parsed.Namespace } +// buildRegisteredResourceValuesByFQN indexes registered resource values by their fully qualified +// name. In non-strict mode a legacy (namespace-less) FQN key is also registered. It is shared by +// NewPolicyDecisionPoint and the JustInTimePDP obligations wiring so both index identically. +func buildRegisteredResourceValuesByFQN(allRegisteredResources []*policy.RegisteredResource, namespacedPolicy bool) (map[string]*policy.RegisteredResourceValue, error) { + allRegisteredResourceValuesByFQN := make(map[string]*policy.RegisteredResourceValue) + for _, rr := range allRegisteredResources { + if err := validateRegisteredResource(rr); err != nil { + return nil, fmt.Errorf("invalid registered resource: %w", err) + } + rrName := rr.GetName() + + for _, v := range rr.GetValues() { + if err := validateRegisteredResourceValue(v); err != nil { + 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 + + if !namespacedPolicy { + legacyQualifiedValue := identifier.FullyQualifiedRegisteredResourceValue{ + Name: rrName, + Value: v.GetValue(), + } + allRegisteredResourceValuesByFQN[legacyQualifiedValue.FQN()] = v + } + } + } + return allRegisteredResourceValuesByFQN, nil +} + // GetDecision evaluates the action on the resources for the entity and returns a decision along with entitlements. func (p *PolicyDecisionPoint) GetDecision( ctx context.Context,