From 85de8c25b0748829632544f61ef1f20f7e079e82 Mon Sep 17 00:00:00 2001 From: Ryan Yanulites Date: Fri, 4 Sep 2026 13:53:10 -0600 Subject: [PATCH 1/5] add pdp test to verify bug --- service/internal/access/v2/pdp_test.go | 92 +++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index f3d98c3003..5c8700cf87 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -14,6 +14,7 @@ import ( "github.com/opentdf/platform/service/logger" "github.com/opentdf/platform/service/policy/actions" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/wrapperspb" ) // Constants for test namespaces @@ -4074,13 +4075,32 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { } attr2ValueFQN := attr2.GetValues()[0].GetFqn() + attr3 := &policy.Attribute{ + Fqn: "https://demo.com/attr/adhoc_3", + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, + Values: []*policy.Value{ + { + Fqn: "https://demo.com/attr/adhoc_3/value/active_value", + Value: "active_value", + Active: wrapperspb.Bool(true), + }, + { + Fqn: "https://demo.com/attr/adhoc_3/value/inactive_value", + Value: "inactive_value", + Active: wrapperspb.Bool(false), + }, + }, + } + attr3ActiveValueFQN := attr3.GetValues()[0].GetFqn() + attr3InactiveValueFQN := attr3.GetValues()[1].GetFqn() + resAttr1ValueFqn := createAttributeValueResource(attr1ValueFQN, attr1ValueFQN) resAttr2ValueFqn := createAttributeValueResource(attr2ValueFQN, attr2ValueFQN) pdp, err := NewPolicyDecisionPoint( ctx, s.logger, - []*policy.Attribute{attr1, attr2}, + []*policy.Attribute{attr1, attr2, attr3}, []*policy.SubjectMapping{}, []*policy.RegisteredResource{}, true, // Allow direct entitlements @@ -4178,6 +4198,76 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { attr2ValueFQN: false, }) }) + + s.Run("not entitled to a deactivated value", func() { + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + { + AttributeValueFqn: attr3InactiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + }, + } + + decision, _, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource(attr3InactiveValueFQN, attr3InactiveValueFQN), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.False(decision.AllPermitted, "deactivated attribute value must not be entitled by a direct entitlement") + s.assertAllDecisionResults(decision, map[string]bool{ + attr3InactiveValueFQN: false, + }) + }) + + s.Run("not entitled to a resource carrying both an active and a deactivated value", func() { + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + { + AttributeValueFqn: attr3ActiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + { + AttributeValueFqn: attr3InactiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + }, + } + + decision, _, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource("mixed-active-state-resource", attr3ActiveValueFQN, attr3InactiveValueFQN), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.False(decision.AllPermitted, "a resource carrying a deactivated value must not be entitled") + s.assertAllDecisionResults(decision, map[string]bool{ + "mixed-active-state-resource": false, + }) + }) + + s.Run("entitled to the active sibling of a deactivated value", func() { + entityRep := &entityresolutionV2.EntityRepresentation{ + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + { + AttributeValueFqn: attr3ActiveValueFQN, + Actions: []string{actions.ActionNameCreate}, + }, + }, + } + + decision, _, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + createAttributeValueResource(attr3ActiveValueFQN, attr3ActiveValueFQN), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + + s.True(decision.AllPermitted, "deactivation of a sibling value must not affect the active value") + s.assertAllDecisionResults(decision, map[string]bool{ + attr3ActiveValueFQN: true, + }) + }) } func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements_StrictNamespacedPolicy() { From 9e6f9c3dc274b45648ac1d0c76515bc5db83a634 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 4 Sep 2026 17:07:55 -0400 Subject: [PATCH 2/5] add bdd tests that should fail --- .../resources/platform.deactivation.template | 188 ++++++++++++++++++ tests-bdd/cukes/steps_attributes.go | 47 +++++ tests-bdd/cukes/steps_localplatform.go | 13 ++ .../deactivated-attribute-values.feature | 65 ++++++ .../features/direct-entitlements.feature | 22 ++ 5 files changed, 335 insertions(+) create mode 100644 tests-bdd/cukes/resources/platform.deactivation.template create mode 100644 tests-bdd/features/deactivated-attribute-values.feature diff --git a/tests-bdd/cukes/resources/platform.deactivation.template b/tests-bdd/cukes/resources/platform.deactivation.template new file mode 100644 index 0000000000..94f31c1c9c --- /dev/null +++ b/tests-bdd/cukes/resources/platform.deactivation.template @@ -0,0 +1,188 @@ +# BDD platform template for attribute value deactivation. +# Tracks platform.template but enables allow_direct_entitlements, which makes +# authorization v2 decide against the full entitlement policy rather than a +# targeted per-FQN fetch. That is the path where deactivated values were still +# reachable, so it is the one the deactivation scenarios must exercise. +logger: + level: debug + type: text + output: stderr +# BDD-specific: scenarios run a full in-process platform and need their own +# schema-isolated database per scenario. +mode: all +db: + host: {{ .pgHost }} + port: {{ .pgPort }} + database: {{ .pgDatabase }} + user: postgres + password: changeme + schema: otdf +services: + authorization: + allow_direct_entitlements: true + kas: + registered_kas_uri: http://{{ .hostname }}:{{ .platformPort }} # Should match what you have registered for *this* KAS in the policy db. + key_management: false # Static development keys are configured below. + preview: + ec_tdf_enabled: false + root_key: a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1 # For local development testing only + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: e1 + alg: ec:secp256r1 + legacy: true + - kid: r1 + alg: rsa:2048 + - kid: r1 + alg: rsa:2048 + legacy: true + entityresolution: + log_level: info + url: http://{{ .hostname }}:{{ .kcPort }}/auth + clientid: "tdf-entity-resolution" + clientsecret: "secret" + realm: "{{ .authRealm }}" + legacykeycloak: true + inferid: + from: + email: true + username: true + # cache_expiration: 30s # disabled unless present and > 0 + # policy is enabled by default in mode 'all' + # policy: + # enabled: true + # list_request_limit_default: 1000 + # list_request_limit_max: 2500 + # authorization: + # entitlement_policy_cache: + # enabled: false + # refresh_interval: 30s +server: + public_hostname: {{ .hostname }} + tls: + enabled: false + cert: ./keys/platform.crt + key: ./keys/platform-key.pem + auth: + enabled: true + enforceDPoP: false + audience: "http://{{ .hostname }}:{{ .platformPort }}" + issuer: http://{{ .hostname }}:{{ .kcPort }}/auth/realms/{{ .authRealm }} + policy: + ## Dot notation is used to access nested claims (i.e. realm_access.roles) + # Claim that represents the user (i.e. email) + username_claim: # preferred_username + # That claim to access groups (i.e. realm_access.roles) + groups_claim: # realm_access.roles + # Claim the represents the idP client ID + client_id_claim: # azp + # Optional external role provider (name is resolved via StartOptions) + # roles_provider: + # name: external + # config: {} # provider-specific (any object) + ## Extends the builtin policy + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + ## Custom policy that overrides builtin policy (see examples https://github.com/casbin/casbin/tree/master/examples) + csv: #| + # p, role:admin, *, *, allow + ## Custom model (see https://casbin.org/docs/syntax-for-models/) + model: #| + # [request_definition] + # r = sub, res, act, obj + # + # [policy_definition] + # p = sub, res, act, obj, eft + # + # [role_definition] + # g = _, _ + # + # [policy_effect] + # e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) + # + # [matchers] + # m = g(r.sub, p.sub) && globOrRegexMatch(r.res, p.res) && globOrRegexMatch(r.act, p.act) && globOrRegexMatch(r.obj, p.obj) + trace: + enabled: false + provider: + name: file # file | otlp + file: + path: "./traces/traces.log" + prettyPrint: true # Optional, default is compact JSON + maxSize: 50 # Optional, default 20MB + maxBackups: 5 # Optional, default 10 + maxAge: 14 # Optional, default 30 days + compress: true # Optional, default false + # otlp: + # protocol: grpc # Optional, defaults to grpc + # endpoint: "localhost:4317" + # insecure: true # Set to false if Jaeger requires TLS + # headers: {} # Add if authentication is needed + # HTTP + # protocol: "http/protobuf" + # endpoint: "http://localhost:4318" # Default OTLP HTTP port + # insecure: true # If collector is just HTTP, not HTTPS + # headers: {} # Add if authentication is needed + cors: + enabled: true + # "*" to allow any origin or a specific domain like "https://yourdomain.com" + allowedorigins: + - "*" + # List of methods. Examples: "GET,POST,PUT" + allowedmethods: + - GET + - POST + - PATCH + - PUT + - DELETE + - OPTIONS + # List of headers that are allowed in a request + allowedheaders: + - Accept + - Accept-Encoding + - Authorization + - Connect-Protocol-Version + - Content-Length + - Content-Type + - Dpop + - X-CSRF-Token + - X-Requested-With + - X-Rewrap-Additional-Context + # List of response headers that browsers are allowed to access + exposedheaders: + - Link + # Sets whether credentials are included in the CORS request + allowcredentials: true + # Sets the maximum age (in seconds) of a specific CORS preflight request + maxage: 3600 + # Additive fields - append to base lists without replacing defaults + # Use these to add custom values without having to copy all defaults + # additionalmethods: [] + # additionalheaders: + # - X-Custom-Header + # additionalexposedheaders: [] + grpc: + reflectionEnabled: true # Default is false + # http: + # # HTTP server configuration + # # Negative values indicate no timeout, default will be used if the timeout is set to 0 + # readTimeout: 15s + # writeTimeout: 15s + # readHeaderTimeout: 10s + # idleTimeout: 20s + # maxHeaderBytes: 1048576 # 1 MB + cryptoProvider: + type: standard + standard: + keys: + - kid: r1 + alg: rsa:2048 + private: {{ .platformKeysDir }}/kas-private.pem + cert: {{ .platformKeysDir }}/kas-cert.pem + - kid: e1 + alg: ec:secp256r1 + private: {{ .platformKeysDir }}/kas-ec-private.pem + cert: {{ .platformKeysDir }}/kas-ec-cert.pem + port: {{ .platformPort }} diff --git a/tests-bdd/cukes/steps_attributes.go b/tests-bdd/cukes/steps_attributes.go index ba8dae68c0..c62ca92b87 100644 --- a/tests-bdd/cukes/steps_attributes.go +++ b/tests-bdd/cukes/steps_attributes.go @@ -128,11 +128,58 @@ func (s *AttributesStepDefinitions) iSendARequestToCreateAnAttributeWithGenerate return ctx, nil } +// iDeactivateTheAttributeValue resolves the value by FQN and deactivates it. A deactivated value +// must no longer entitle an entity nor be satisfiable on a resource, so decisions and KAS rewraps +// touching it must fail closed. +func (s *AttributesStepDefinitions) iDeactivateTheAttributeValue(ctx context.Context, fqn string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + value, err := scenarioContext.GetAttributeValue(ctx, strings.TrimSpace(fqn)) + if err != nil { + return ctx, fmt.Errorf("resolve attribute value %s: %w", fqn, err) + } + + _, err = scenarioContext.SDK.Attributes.DeactivateAttributeValue(ctx, &attributes.DeactivateAttributeValueRequest{ + Id: value.GetId(), + }) + if err != nil { + return ctx, fmt.Errorf("deactivate attribute value %s: %w", fqn, err) + } + return ctx, nil +} + +// iDeactivateTheAttributeDefinition resolves the definition by FQN and deactivates it. Deactivating +// a definition does not cascade to its values in the database, so every decision path must deny on +// the definition's own state. +func (s *AttributesStepDefinitions) iDeactivateTheAttributeDefinition(ctx context.Context, fqn string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + trimmed := strings.TrimSpace(fqn) + resp, err := scenarioContext.SDK.Attributes.GetAttribute(ctx, &attributes.GetAttributeRequest{ + Identifier: &attributes.GetAttributeRequest_Fqn{Fqn: trimmed}, + }) + if err != nil { + return ctx, fmt.Errorf("resolve attribute definition %s: %w", fqn, err) + } + + _, err = scenarioContext.SDK.Attributes.DeactivateAttribute(ctx, &attributes.DeactivateAttributeRequest{ + Id: resp.GetAttribute().GetId(), + }) + if err != nil { + return ctx, fmt.Errorf("deactivate attribute definition %s: %w", fqn, err) + } + return ctx, nil +} + func RegisterAttributeStepDefinitions(ctx *godog.ScenarioContext, x *PlatformTestSuiteContext) { stepDefinitions := AttributesStepDefinitions{ PlatformCukesContext: x, } ctx.Step(`^a (anyOf|allOf|hierarchy) attribute definition with values: "([^"]*)"$`, stepDefinitions.aAttributeDef) + ctx.Step(`^I deactivate the attribute value "([^"]*)"$`, stepDefinitions.iDeactivateTheAttributeValue) + ctx.Step(`^I deactivate the attribute definition "([^"]*)"$`, stepDefinitions.iDeactivateTheAttributeDefinition) ctx.Step(`^I send a request to create an attribute with:$`, stepDefinitions.iSendARequestToCreateAnAttributeWith) ctx.Step(`^I send a request to create an attribute referenced as "([^"]*)" in namespace "([^"]*)" named "([^"]*)" with rule "([^"]*)" and (\d+) generated values$`, stepDefinitions.iSendARequestToCreateAnAttributeWithGeneratedValues) } diff --git a/tests-bdd/cukes/steps_localplatform.go b/tests-bdd/cukes/steps_localplatform.go index 042133d1ef..8dde770ab2 100644 --- a/tests-bdd/cukes/steps_localplatform.go +++ b/tests-bdd/cukes/steps_localplatform.go @@ -296,6 +296,18 @@ func (s *LocalPlatformStepDefinitions) aDefaultLocalPlatform(ctx context.Context }) } +// aDefaultLocalPlatformWithTemplate provisions the demo policy of `a default local platform` on a +// platform configured from a custom template, for scenarios that need both the demo attributes and +// non-default service config. +func (s *LocalPlatformStepDefinitions) aDefaultLocalPlatformWithTemplate(ctx context.Context, platformTemplate string) (context.Context, error) { + kt := template.Must(template.New("kc").Parse(keycloakBaseTemplate)) + return s.commonLocalPlatform(ctx, &platformStartOptions{ + platformProvisionPath: &platformTemplate, + kcProvisionPath: kt, + provisionDefaultPolicy: true, + }) +} + func (s *LocalPlatformStepDefinitions) iUseThePlatformAs(ctx context.Context, role string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) clientIDByRole := map[string]string{ @@ -598,6 +610,7 @@ func RegisterLocalPlatformStepDefinitions(ctx *godog.ScenarioContext, x *Platfor } ctx.Step(`^an empty local platform$`, platformStepDefinitions.aEmptyLocalPlatform) ctx.Step(`^a default local platform$`, platformStepDefinitions.aDefaultLocalPlatform) + ctx.Step(`^a default local platform with platform template "([^"]*)"$`, platformStepDefinitions.aDefaultLocalPlatformWithTemplate) ctx.Step(`^I use the platform as "([^"]*)"$`, platformStepDefinitions.iUseThePlatformAs) ctx.Step(`^a user exists with username "([^"]*)" and email "([^"]*)" and the following attributes:$`, platformStepDefinitions.aUser) ctx.Step(`^a local platform with platform template "([^"]*)" and keycloak template "([^"]*)"$`, platformStepDefinitions.aLocalPlatformWithTemplates) diff --git a/tests-bdd/features/deactivated-attribute-values.feature b/tests-bdd/features/deactivated-attribute-values.feature new file mode 100644 index 0000000000..edb9b8b65d --- /dev/null +++ b/tests-bdd/features/deactivated-attribute-values.feature @@ -0,0 +1,65 @@ +@deactivated-attribute-values +Feature: Deactivated attribute values deny decrypt + A deactivated attribute value — or a value whose definition is deactivated — + must fail closed: it can neither entitle an entity nor be satisfied on a + resource, so a TDF already bound to it stops being decryptable the moment the + deactivation lands. + + These scenarios run against a platform with allow_direct_entitlements + enabled, which makes authorization v2 decide against the full entitlement + policy instead of a targeted per-FQN fetch. Deactivated values are present in + that full policy load, which is how they remained decryptable. + + The demo policy loaded by the default platform is used: + + demo.com/attr/department rule: ANY_OF + engineering, finance, hr + + demo.com/attr/classification rule: HIERARCHY (public < internal < confidential < secret) + + Each scenario deactivates a value, so the feature is deliberately not + @stateless — every scenario gets its own platform and policy database. + + Background: + Given a user exists with username "alice" and email "alice@demo.com" and the following attributes: + | name | value | + | department | ["engineering"] | + | classification | ["confidential"] | + And a default local platform with platform template "cukes/resources/platform.deactivation.template" + And a user token for "alice" stored as "alice_tok" + + Scenario: ANY_OF — deactivating the value on the TDF denies decrypt + When I encrypt plaintext "hello engineering" with attributes "https://demo.com/attr/department/value/engineering" stored as "tdf_dept" + And using token "alice_tok", decrypt "tdf_dept" stored as "plain_dept_before" + Then the decryption stored as "plain_dept_before" should succeed with plaintext "hello engineering" + When I deactivate the attribute value "https://demo.com/attr/department/value/engineering" + And using token "alice_tok", decrypt "tdf_dept" stored as "plain_dept_after" + Then the decryption stored as "plain_dept_after" should be denied + + Scenario: HIERARCHY — deactivating the value on the TDF denies decrypt + When I encrypt plaintext "internal memo" with attributes "https://demo.com/attr/classification/value/internal" stored as "tdf_class" + And using token "alice_tok", decrypt "tdf_class" stored as "plain_class_before" + Then the decryption stored as "plain_class_before" should succeed with plaintext "internal memo" + When I deactivate the attribute value "https://demo.com/attr/classification/value/internal" + And using token "alice_tok", decrypt "tdf_class" stored as "plain_class_after" + Then the decryption stored as "plain_class_after" should be denied + + # Deactivating a definition does not cascade to its values in the database — each value keeps + # active = true — so the deny must come from the definition's own state. + Scenario: Deactivating the attribute definition denies decrypt of its values + When I encrypt plaintext "hello engineering" with attributes "https://demo.com/attr/department/value/engineering" stored as "tdf_defn" + And using token "alice_tok", decrypt "tdf_defn" stored as "plain_defn_before" + Then the decryption stored as "plain_defn_before" should succeed with plaintext "hello engineering" + When I deactivate the attribute definition "https://demo.com/attr/department" + And using token "alice_tok", decrypt "tdf_defn" stored as "plain_defn_after" + Then the decryption stored as "plain_defn_after" should be denied + + # alice reaches `public` only by hierarchy cascade from her entitled + # `confidential`. Deactivating `confidential` must stop the cascade. + Scenario: HIERARCHY — deactivating the entitled value stops the cascade to lower values + When I encrypt plaintext "public notice" with attributes "https://demo.com/attr/classification/value/public" stored as "tdf_cascade" + And using token "alice_tok", decrypt "tdf_cascade" stored as "plain_cascade_before" + Then the decryption stored as "plain_cascade_before" should succeed with plaintext "public notice" + When I deactivate the attribute value "https://demo.com/attr/classification/value/confidential" + And using token "alice_tok", decrypt "tdf_cascade" stored as "plain_cascade_after" + Then the decryption stored as "plain_cascade_after" should be denied diff --git a/tests-bdd/features/direct-entitlements.feature b/tests-bdd/features/direct-entitlements.feature index e87702a061..932580a9df 100644 --- a/tests-bdd/features/direct-entitlements.feature +++ b/tests-bdd/features/direct-entitlements.feature @@ -65,6 +65,28 @@ Feature: Direct entitlements decisioning Then the response should be successful And I should get a "PERMIT" decision response + # A direct entitlement is supplied by the caller, so it must not be able to resurrect an + # attribute value that policy has deactivated. + Scenario: Direct entitlement denies a deactivated attribute value + Given there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + And I deactivate the attribute value "https://example.com/attr/department/value/eng" + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "DENY" decision response + + # Deactivating a definition does not cascade to its values, so the value is still active here. + # The full entitlement policy load drops deactivated definitions outright, so the resource FQN + # resolves to nothing and the decision fails closed as NOT_FOUND rather than as a DENY. + Scenario: Direct entitlement denies a value whose definition is deactivated + Given there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + And I deactivate the attribute definition "https://example.com/attr/department" + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be unsuccessful + Scenario: Subject mapping and direct entitlement together satisfy an ALL_OF resource Given there is a claims subject entity referenced as "alice" with direct entitlements: | attribute_value_fqn | actions | From 13bdbfc036d66c17568f3453997b737c830d0119 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 4 Sep 2026 17:17:08 -0400 Subject: [PATCH 3/5] fix format, remove template --- .../resources/platform.deactivation.template | 188 ------------------ .../deactivated-attribute-values.feature | 16 +- 2 files changed, 9 insertions(+), 195 deletions(-) delete mode 100644 tests-bdd/cukes/resources/platform.deactivation.template diff --git a/tests-bdd/cukes/resources/platform.deactivation.template b/tests-bdd/cukes/resources/platform.deactivation.template deleted file mode 100644 index 94f31c1c9c..0000000000 --- a/tests-bdd/cukes/resources/platform.deactivation.template +++ /dev/null @@ -1,188 +0,0 @@ -# BDD platform template for attribute value deactivation. -# Tracks platform.template but enables allow_direct_entitlements, which makes -# authorization v2 decide against the full entitlement policy rather than a -# targeted per-FQN fetch. That is the path where deactivated values were still -# reachable, so it is the one the deactivation scenarios must exercise. -logger: - level: debug - type: text - output: stderr -# BDD-specific: scenarios run a full in-process platform and need their own -# schema-isolated database per scenario. -mode: all -db: - host: {{ .pgHost }} - port: {{ .pgPort }} - database: {{ .pgDatabase }} - user: postgres - password: changeme - schema: otdf -services: - authorization: - allow_direct_entitlements: true - kas: - registered_kas_uri: http://{{ .hostname }}:{{ .platformPort }} # Should match what you have registered for *this* KAS in the policy db. - key_management: false # Static development keys are configured below. - preview: - ec_tdf_enabled: false - root_key: a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1 # For local development testing only - keyring: - - kid: e1 - alg: ec:secp256r1 - - kid: e1 - alg: ec:secp256r1 - legacy: true - - kid: r1 - alg: rsa:2048 - - kid: r1 - alg: rsa:2048 - legacy: true - entityresolution: - log_level: info - url: http://{{ .hostname }}:{{ .kcPort }}/auth - clientid: "tdf-entity-resolution" - clientsecret: "secret" - realm: "{{ .authRealm }}" - legacykeycloak: true - inferid: - from: - email: true - username: true - # cache_expiration: 30s # disabled unless present and > 0 - # policy is enabled by default in mode 'all' - # policy: - # enabled: true - # list_request_limit_default: 1000 - # list_request_limit_max: 2500 - # authorization: - # entitlement_policy_cache: - # enabled: false - # refresh_interval: 30s -server: - public_hostname: {{ .hostname }} - tls: - enabled: false - cert: ./keys/platform.crt - key: ./keys/platform-key.pem - auth: - enabled: true - enforceDPoP: false - audience: "http://{{ .hostname }}:{{ .platformPort }}" - issuer: http://{{ .hostname }}:{{ .kcPort }}/auth/realms/{{ .authRealm }} - policy: - ## Dot notation is used to access nested claims (i.e. realm_access.roles) - # Claim that represents the user (i.e. email) - username_claim: # preferred_username - # That claim to access groups (i.e. realm_access.roles) - groups_claim: # realm_access.roles - # Claim the represents the idP client ID - client_id_claim: # azp - # Optional external role provider (name is resolved via StartOptions) - # roles_provider: - # name: external - # config: {} # provider-specific (any object) - ## Extends the builtin policy - extension: | - g, opentdf-admin, role:admin - g, opentdf-standard, role:standard - ## Custom policy that overrides builtin policy (see examples https://github.com/casbin/casbin/tree/master/examples) - csv: #| - # p, role:admin, *, *, allow - ## Custom model (see https://casbin.org/docs/syntax-for-models/) - model: #| - # [request_definition] - # r = sub, res, act, obj - # - # [policy_definition] - # p = sub, res, act, obj, eft - # - # [role_definition] - # g = _, _ - # - # [policy_effect] - # e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) - # - # [matchers] - # m = g(r.sub, p.sub) && globOrRegexMatch(r.res, p.res) && globOrRegexMatch(r.act, p.act) && globOrRegexMatch(r.obj, p.obj) - trace: - enabled: false - provider: - name: file # file | otlp - file: - path: "./traces/traces.log" - prettyPrint: true # Optional, default is compact JSON - maxSize: 50 # Optional, default 20MB - maxBackups: 5 # Optional, default 10 - maxAge: 14 # Optional, default 30 days - compress: true # Optional, default false - # otlp: - # protocol: grpc # Optional, defaults to grpc - # endpoint: "localhost:4317" - # insecure: true # Set to false if Jaeger requires TLS - # headers: {} # Add if authentication is needed - # HTTP - # protocol: "http/protobuf" - # endpoint: "http://localhost:4318" # Default OTLP HTTP port - # insecure: true # If collector is just HTTP, not HTTPS - # headers: {} # Add if authentication is needed - cors: - enabled: true - # "*" to allow any origin or a specific domain like "https://yourdomain.com" - allowedorigins: - - "*" - # List of methods. Examples: "GET,POST,PUT" - allowedmethods: - - GET - - POST - - PATCH - - PUT - - DELETE - - OPTIONS - # List of headers that are allowed in a request - allowedheaders: - - Accept - - Accept-Encoding - - Authorization - - Connect-Protocol-Version - - Content-Length - - Content-Type - - Dpop - - X-CSRF-Token - - X-Requested-With - - X-Rewrap-Additional-Context - # List of response headers that browsers are allowed to access - exposedheaders: - - Link - # Sets whether credentials are included in the CORS request - allowcredentials: true - # Sets the maximum age (in seconds) of a specific CORS preflight request - maxage: 3600 - # Additive fields - append to base lists without replacing defaults - # Use these to add custom values without having to copy all defaults - # additionalmethods: [] - # additionalheaders: - # - X-Custom-Header - # additionalexposedheaders: [] - grpc: - reflectionEnabled: true # Default is false - # http: - # # HTTP server configuration - # # Negative values indicate no timeout, default will be used if the timeout is set to 0 - # readTimeout: 15s - # writeTimeout: 15s - # readHeaderTimeout: 10s - # idleTimeout: 20s - # maxHeaderBytes: 1048576 # 1 MB - cryptoProvider: - type: standard - standard: - keys: - - kid: r1 - alg: rsa:2048 - private: {{ .platformKeysDir }}/kas-private.pem - cert: {{ .platformKeysDir }}/kas-cert.pem - - kid: e1 - alg: ec:secp256r1 - private: {{ .platformKeysDir }}/kas-ec-private.pem - cert: {{ .platformKeysDir }}/kas-ec-cert.pem - port: {{ .platformPort }} diff --git a/tests-bdd/features/deactivated-attribute-values.feature b/tests-bdd/features/deactivated-attribute-values.feature index edb9b8b65d..afe6d983d7 100644 --- a/tests-bdd/features/deactivated-attribute-values.feature +++ b/tests-bdd/features/deactivated-attribute-values.feature @@ -5,10 +5,12 @@ Feature: Deactivated attribute values deny decrypt resource, so a TDF already bound to it stops being decryptable the moment the deactivation lands. - These scenarios run against a platform with allow_direct_entitlements - enabled, which makes authorization v2 decide against the full entitlement - policy instead of a targeted per-FQN fetch. Deactivated values are present in - that full policy load, which is how they remained decryptable. + These scenarios reuse the dynamic value mappings platform template purely for + its allow_dynamic_value_mappings flag: that flag makes authorization v2 decide + against the full entitlement policy instead of a targeted per-FQN fetch. + Deactivated values are present in that full policy load, which is how they + remained decryptable. The targeted fetch already errors on an inactive value, + so on the stock template these scenarios would pass even with the bug. The demo policy loaded by the default platform is used: @@ -17,15 +19,15 @@ Feature: Deactivated attribute values deny decrypt demo.com/attr/classification rule: HIERARCHY (public < internal < confidential < secret) - Each scenario deactivates a value, so the feature is deliberately not - @stateless — every scenario gets its own platform and policy database. + Each scenario deactivates a value, so the feature is deliberately untagged + for stateless reuse: every scenario gets its own platform and policy database. Background: Given a user exists with username "alice" and email "alice@demo.com" and the following attributes: | name | value | | department | ["engineering"] | | classification | ["confidential"] | - And a default local platform with platform template "cukes/resources/platform.deactivation.template" + And a default local platform with platform template "cukes/resources/platform.dynamic_value_mappings.template" And a user token for "alice" stored as "alice_tok" Scenario: ANY_OF — deactivating the value on the TDF denies decrypt From c1400d59f546172b58bd54e611da810f3a37280d Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 4 Sep 2026 17:34:04 -0400 Subject: [PATCH 4/5] add the fix --- service/internal/access/v2/helpers.go | 37 +- service/internal/access/v2/pdp.go | 46 ++ .../access/v2/pdp_deactivated_test.go | 455 ++++++++++++++++++ .../deactivated-attribute-values.feature | 1 + .../features/direct-entitlements.feature | 6 +- 5 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 service/internal/access/v2/pdp_deactivated_test.go diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 4af02e632c..35fe96ba0a 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -15,6 +15,7 @@ import ( "github.com/opentdf/platform/service/internal/access/v2/obligations" "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" "github.com/opentdf/platform/service/logger" + "google.golang.org/protobuf/types/known/wrapperspb" ) var ( @@ -25,6 +26,19 @@ var ( ErrInvalidDynamicValueMapping = errors.New("access: invalid dynamic value mapping") ) +// isExplicitlyInactive reports whether an active state was loaded and is false. An unset state is +// not inactive: targeted lookups, synthetic values, and in-memory fixtures all leave it unset. +func isExplicitlyInactive(active *wrapperspb.BoolValue) bool { + return active != nil && !active.GetValue() +} + +// isDeactivated reports whether an attribute value, or the definition owning it, is deactivated. +// Deactivated values must neither entitle an entity nor be entitleable on a resource. +func isDeactivated(attributeAndValue *attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue) bool { + return isExplicitlyInactive(attributeAndValue.GetValue().GetActive()) || + isExplicitlyInactive(attributeAndValue.GetAttribute().GetActive()) +} + // getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions map func getDefinition(valueFQN string, allDefinitionsByDefFQN map[string]*policy.Attribute) (*policy.Attribute, error) { parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](valueFQN) @@ -112,7 +126,7 @@ func populateLowerValuesIfHierarchy( entitledActionsSet[action.GetName()] = action } for _, value := range definition.GetValues() { - if lower { + if lower && !isExplicitlyInactive(value.GetActive()) { alreadyEntitledActions, exists := entitledActionsPerAttributeValueFqn[value.GetFqn()] if !exists { entitledActionsPerAttributeValueFqn[value.GetFqn()] = entitledActions @@ -165,6 +179,9 @@ func populateHigherValuesIfHierarchy( ) continue } + if isDeactivated(fullValue) { + continue + } decisionableAttributes[value.GetFqn()] = &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ Value: fullValue.GetValue(), Attribute: definition, @@ -254,6 +271,15 @@ func getResourceDecisionableAttributes( attributeAndValue, ok := entitleableAttributesByValueFQN[attrValueFQN] + // A deactivated value is left out of the decisionable set so the resource carrying it is + // denied downstream, and so it is never synthesized as an ad-hoc value below. + if ok && isDeactivated(attributeAndValue) { + logger.WarnContext(ctx, "deactivated attribute value on resource - denying access", + slog.String("attribute_value_fqn", attrValueFQN), + ) + continue + } + if !ok { // The value FQN is not a concrete policy value. A synthetic value is created // when either direct entitlements are enabled (experimental) OR the parent @@ -266,6 +292,15 @@ func getResourceDecisionableAttributes( continue } + // A deactivated definition cannot back a synthetic value, or an ad-hoc value under a + // deactivated definition would remain satisfiable. + if isExplicitlyInactive(parentDefinition.GetActive()) { + logger.WarnContext(ctx, "deactivated attribute definition on resource - denying access", + slog.String("attribute_value_fqn", attrValueFQN), + ) + continue + } + _, hasDynamicMapping := dynamicMappingsByDefinitionFQN[parentDefinition.GetFqn()] if !allowDirectEntitlements && !hasDynamicMapping { // neither path enabled for this value: add to not found list and skip diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 5a1eeda521..0ba0379a81 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -343,6 +343,12 @@ func (p *PolicyDecisionPoint) GetDecision( for _, directEntitlement := range entityRepresentation.GetDirectEntitlements() { fqn := directEntitlement.GetAttributeValueFqn() + if p.isDeactivatedValueFQN(fqn) { + l.DebugContext(ctx, "skipping direct entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", fqn), + ) + continue + } actionNames := directEntitlement.GetActions() // In strict namespaced-policy mode, direct-entitlement actions must carry // the same namespace context as the entitled attribute value so they can @@ -386,6 +392,12 @@ func (p *PolicyDecisionPoint) GetDecision( return nil, nil, fmt.Errorf("%w: %w", ErrDynamicValueMappingEvaluation, err) } for fqn, actions := range dynamicEntitledFQNsToActions { + if p.isDeactivatedValueFQN(fqn) { + l.DebugContext(ctx, "skipping dynamic value mapping entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", fqn), + ) + continue + } entitledFQNsToActions[fqn] = append(entitledFQNsToActions[fqn], actions...) } l.DebugContext(ctx, "evaluated dynamic value mappings", slog.Any("dynamic_entitled_value_fqns_to_actions", dynamicEntitledFQNsToActions)) @@ -460,6 +472,13 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( attrVal := aav.GetAttributeValue() attrValFQN := attrVal.GetFqn() + if p.isDeactivatedValueFQN(attrValFQN) { + l.DebugContext(ctx, "skipping registered resource entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", attrValFQN), + ) + continue + } + requiredNamespaceFQN := "" if attrAndValue, ok2 := decisionableAttributes[attrValFQN]; ok2 { requiredNamespaceFQN = attrAndValue.GetAttribute().GetNamespace().GetFqn() @@ -559,6 +578,12 @@ func (p *PolicyDecisionPoint) GetEntitlements( actionsPerAttributeValueFqn := make(map[string]*authz.EntityEntitlements_ActionsList) for valueFQN, actions := range fqnsToActions { + if p.isDeactivatedValueFQN(valueFQN) { + l.DebugContext(ctx, "skipping entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", valueFQN), + ) + continue + } // If already entitled (such as via a higher entitled comprehensive hierarchy attr value), merge with existing if alreadyEntitled, ok := actionsPerAttributeValueFqn[valueFQN]; ok { actions = mergeDeduplicatedActions(make(map[string]*policy.Action), actions, alreadyEntitled.GetActions()) @@ -615,6 +640,13 @@ func (p *PolicyDecisionPoint) GetEntitlementsRegisteredResource( attrVal := aav.GetAttributeValue() attrValFQN := attrVal.GetFqn() + if p.isDeactivatedValueFQN(attrValFQN) { + l.DebugContext(ctx, "skipping entitlement of deactivated attribute value", + slog.String("attribute_value_fqn", attrValFQN), + ) + continue + } + actionsList, actionsAreOK := actionsPerAttributeValueFqn[attrValFQN] if !actionsAreOK { actionsList = &authz.EntityEntitlements_ActionsList{ @@ -652,3 +684,17 @@ func (p *PolicyDecisionPoint) GetEntitlementsRegisteredResource( return result, nil } + +// isDeactivatedValueFQN reports whether the value FQN, or the definition owning it, is deactivated. +// An FQN unknown to policy under an active definition is not deactivated: it is either denied or +// synthesized by the ad-hoc value paths. +func (p *PolicyDecisionPoint) isDeactivatedValueFQN(valueFQN string) bool { + if attributeAndValue, ok := p.allEntitleableAttributesByValueFQN[valueFQN]; ok { + return isDeactivated(attributeAndValue) + } + definition, err := getDefinition(valueFQN, p.allAttributesByDefinitionFQN) + if err != nil { + return false + } + return isExplicitlyInactive(definition.GetActive()) +} diff --git a/service/internal/access/v2/pdp_deactivated_test.go b/service/internal/access/v2/pdp_deactivated_test.go new file mode 100644 index 0000000000..48cca052b1 --- /dev/null +++ b/service/internal/access/v2/pdp_deactivated_test.go @@ -0,0 +1,455 @@ +package access + +import ( + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/opentdf/platform/protocol/go/policy" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// Deactivating an attribute value must fail closed on every decision path: data already tagged +// with the value can no longer be decrypted, and no entitlement source may resurrect it. +// Regression coverage for a TDF remaining decryptable after its value was deactivated. + +const deactivatedTestNamespace = "deactivation.example.com" + +var ( + testDeactivatedProjectFQN = createAttrFQN(deactivatedTestNamespace, "project") + testDeactivatedProjectActive = createAttrValueFQN(deactivatedTestNamespace, "project", "active") + testDeactivatedProjectInactive = createAttrValueFQN(deactivatedTestNamespace, "project", "inactive") + + testDeactivatedClearanceFQN = createAttrFQN(deactivatedTestNamespace, "clearance") + testDeactivatedClearanceHighInactive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "high") + testDeactivatedClearanceLowActive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "low") + + testDeactivatedArchivedFQN = createAttrFQN(deactivatedTestNamespace, "archived") + testDeactivatedArchivedValue = createAttrValueFQN(deactivatedTestNamespace, "archived", "value1") + testDeactivatedArchivedAdHocVal = createAttrValueFQN(deactivatedTestNamespace, "archived", "adhoc") +) + +// deactivationProjectAttr is an ANY_OF definition with one active and one deactivated value. +func deactivationProjectAttr() *policy.Attribute { + return &policy.Attribute{ + Fqn: testDeactivatedProjectFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Namespace: &policy.Namespace{Name: deactivatedTestNamespace, Fqn: "https://" + deactivatedTestNamespace}, + Values: []*policy.Value{ + {Fqn: testDeactivatedProjectActive, Value: "active", Active: wrapperspb.Bool(true)}, + {Fqn: testDeactivatedProjectInactive, Value: "inactive", Active: wrapperspb.Bool(false)}, + }, + } +} + +// deactivationClearanceAttr is a HIERARCHY definition whose highest value is deactivated. +func deactivationClearanceAttr() *policy.Attribute { + return &policy.Attribute{ + Fqn: testDeactivatedClearanceFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + Namespace: &policy.Namespace{Name: deactivatedTestNamespace, Fqn: "https://" + deactivatedTestNamespace}, + Values: []*policy.Value{ + {Fqn: testDeactivatedClearanceHighInactive, Value: "high", Active: wrapperspb.Bool(false)}, + {Fqn: testDeactivatedClearanceLowActive, Value: "low", Active: wrapperspb.Bool(true)}, + }, + } +} + +// deactivationArchivedAttr is a deactivated ANY_OF definition whose values are all still active. +// Deactivating a definition must deny its values even though each value is individually active. +func deactivationArchivedAttr() *policy.Attribute { + return &policy.Attribute{ + Fqn: testDeactivatedArchivedFQN, + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + Active: wrapperspb.Bool(false), + Namespace: &policy.Namespace{Name: deactivatedTestNamespace, Fqn: "https://" + deactivatedTestNamespace}, + Values: []*policy.Value{ + {Fqn: testDeactivatedArchivedValue, Value: "value1", Active: wrapperspb.Bool(true)}, + }, + } +} + +// Test_GetDecision_DeactivatedValue_SubjectMappings covers the standard access PDP path: an entity +// entitled through a subject mapping on a value that is later deactivated. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_SubjectMappings() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectInactive, "inactive", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"inactive"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-both-projects", map[string]interface{}{ + "project": []interface{}{"active", "inactive"}, + }) + + s.Run("resource tagged with the deactivated value is denied", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.AllPermitted, "subject mapping must not entitle a deactivated value") + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("ANY_OF resource carrying the deactivated value alongside an active one is denied", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource("mixed-state-resource", testDeactivatedProjectActive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.AllPermitted, "a resource carrying a deactivated value must fail closed even under ANY_OF") + }) + + s.Run("active sibling value is unaffected", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.AllPermitted) + s.Contains(entitlements, testDeactivatedProjectActive) + }) +} + +// Test_GetDecision_DeactivatedValue_Hierarchy asserts a deactivated higher hierarchy value neither +// entitles itself nor cascades entitlement down to the active values beneath it. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_Hierarchy() { + ctx := s.T().Context() + + attr := deactivationClearanceAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedClearanceHighInactive, "high", + []*policy.Action{testActionRead}, ".properties.clearance", []string{"high"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-high-clearance", map[string]interface{}{ + "clearance": "high", + }) + + s.Run("deactivated highest value denies the resource tagged with it", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedClearanceHighInactive, testDeactivatedClearanceHighInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + }) + + s.Run("deactivated higher value does not entitle a lower active value", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedClearanceLowActive, testDeactivatedClearanceLowActive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "hierarchy must not cascade entitlement from a deactivated value") + }) +} + +// Test_GetDecision_DeactivatedValue_DynamicValueMappings covers the experimental dynamic, +// definition-level value mapping path. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_DynamicValueMappings() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + mapping := &policy.DynamicValueMapping{ + AttributeDefinition: attr, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: ".properties.project[]", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + }, + Actions: []*policy.Action{testActionRead}, + Namespace: attr.GetNamespace(), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, []*policy.SubjectMapping{}, nil, + false, false, WithDynamicValueMappings([]*policy.DynamicValueMapping{mapping}, true)) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-dynamic", map[string]interface{}{ + "project": []interface{}{"active", "inactive"}, + }) + + s.Run("dynamic mapping does not entitle the deactivated value", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("dynamic mapping still entitles the active value", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) +} + +// Test_GetDecision_DeactivatedValue_DirectEntitlements covers the direct-entitlement path where +// the entitled value FQN is carried on the entity representation rather than in policy. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_DirectEntitlements() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, []*policy.SubjectMapping{}, nil, true, false) + s.Require().NoError(err) + + entity := &entityresolutionV2.EntityRepresentation{ + OriginalId: "entity-direct", + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + {AttributeValueFqn: testDeactivatedProjectActive, Actions: []string{testActionRead.GetName()}}, + {AttributeValueFqn: testDeactivatedProjectInactive, Actions: []string{testActionRead.GetName()}}, + }, + } + + s.Run("direct entitlement does not entitle the deactivated value", func() { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("direct entitlement still entitles the active value", func() { + decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) +} + +// Test_GetDecision_DeactivatedValue_RegisteredResources covers registered resources on both sides +// of a decision: as the entity being entitled, and as the resource being accessed. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_RegisteredResources() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + regResName := "deactivation_service" + entityRegResValueFQN := createRegisteredResourceValueFQN("", regResName, "entity") + inactiveRegResValueFQN := createRegisteredResourceValueFQN("", regResName, "tagged_inactive") + activeRegResValueFQN := createRegisteredResourceValueFQN("", regResName, "tagged_active") + + actionAttributeValue := func(fqn, value string) *policy.RegisteredResourceValue_ActionAttributeValue { + return &policy.RegisteredResourceValue_ActionAttributeValue{ + Action: testActionRead, + AttributeValue: &policy.Value{Fqn: fqn, Value: value}, + } + } + + regRes := &policy.RegisteredResource{ + Name: regResName, + Values: []*policy.RegisteredResourceValue{ + { + Value: "entity", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + actionAttributeValue(testDeactivatedProjectActive, "active"), + actionAttributeValue(testDeactivatedProjectInactive, "inactive"), + }, + }, + { + Value: "tagged_inactive", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{actionAttributeValue(testDeactivatedProjectInactive, "inactive")}, + }, + { + Value: "tagged_active", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{actionAttributeValue(testDeactivatedProjectActive, "active")}, + }, + }, + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, []*policy.SubjectMapping{}, + []*policy.RegisteredResource{regRes}, false, false) + s.Require().NoError(err) + + s.Run("registered resource entity is not entitled to the deactivated value", func() { + decision, entitlements, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted) + s.NotContains(entitlements, testDeactivatedProjectInactive) + }) + + s.Run("registered resource entity remains entitled to the active value", func() { + decision, _, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) + + s.Run("registered resource tagged with the deactivated value is denied as a resource", func() { + decision, _, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createRegisteredResource("reg-res-inactive", inactiveRegResValueFQN), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "a registered resource tagged with a deactivated value must fail closed") + }) + + s.Run("registered resource tagged with the active value is still permitted", func() { + decision, _, err := pdp.GetDecisionRegisteredResource(ctx, entityRegResValueFQN, testActionRead, []*authz.Resource{ + createRegisteredResource("reg-res-active", activeRegResValueFQN), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) + + s.Run("registered resource entitlements omit the deactivated value", func() { + entitlements, err := pdp.GetEntitlementsRegisteredResource(ctx, entityRegResValueFQN, false) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + s.Contains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectActive) + s.NotContains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectInactive) + }) +} + +// Test_GetDecision_DeactivatedDefinition covers deactivation of the attribute definition rather +// than an individual value. Every value under it must be denied even though the values themselves +// are still active, including ad-hoc values synthesized by the direct entitlement and dynamic value +// mapping paths. +func (s *PDPTestSuite) Test_GetDecision_DeactivatedDefinition() { + ctx := s.T().Context() + + archived := deactivationArchivedAttr() + project := deactivationProjectAttr() + allAttrs := []*policy.Attribute{archived, project} + + s.Run("subject mapping on a value of a deactivated definition denies", func() { + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedArchivedValue, "value1", + []*policy.Action{testActionRead}, ".properties.archived[]", []string{"value1"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + } + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-archived", map[string]interface{}{ + "archived": []interface{}{"value1"}, + "project": []interface{}{"active"}, + }) + + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedArchivedValue, testDeactivatedArchivedValue), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "a value under a deactivated definition must not be satisfiable") + s.NotContains(entitlements, testDeactivatedArchivedValue) + + // A value under an active definition is unaffected. + decision, _, err = pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), + }) + s.Require().NoError(err) + s.True(decision.AllPermitted) + }) + + s.Run("direct entitlement on a deactivated definition denies known and ad-hoc values", func() { + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, []*policy.SubjectMapping{}, nil, true, false) + s.Require().NoError(err) + + entity := &entityresolutionV2.EntityRepresentation{ + OriginalId: "entity-direct-archived", + DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ + {AttributeValueFqn: testDeactivatedArchivedValue, Actions: []string{testActionRead.GetName()}}, + {AttributeValueFqn: testDeactivatedArchivedAdHocVal, Actions: []string{testActionRead.GetName()}}, + }, + } + + for _, resourceFQN := range []string{testDeactivatedArchivedValue, testDeactivatedArchivedAdHocVal} { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(resourceFQN, resourceFQN), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "direct entitlement must not permit %s under a deactivated definition", resourceFQN) + s.NotContains(entitlements, resourceFQN) + } + }) + + s.Run("dynamic value mapping on a deactivated definition denies", func() { + mapping := &policy.DynamicValueMapping{ + AttributeDefinition: archived, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: ".properties.archived[]", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + }, + Actions: []*policy.Action{testActionRead}, + Namespace: archived.GetNamespace(), + } + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, []*policy.SubjectMapping{}, nil, + false, false, WithDynamicValueMappings([]*policy.DynamicValueMapping{mapping}, true)) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-dynamic-archived", map[string]interface{}{ + "archived": []interface{}{"value1", "adhoc"}, + }) + + for _, resourceFQN := range []string{testDeactivatedArchivedValue, testDeactivatedArchivedAdHocVal} { + decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ + createAttributeValueResource(resourceFQN, resourceFQN), + }) + s.Require().NoError(err) + s.False(decision.AllPermitted, "dynamic value mapping must not permit %s under a deactivated definition", resourceFQN) + s.NotContains(entitlements, resourceFQN) + } + }) + + s.Run("entitlements omit values of a deactivated definition", func() { + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedArchivedValue, "value1", + []*policy.Action{testActionRead}, ".properties.archived[]", []string{"value1"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + } + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, allAttrs, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-archived-entitlements", map[string]interface{}{ + "archived": []interface{}{"value1"}, + "project": []interface{}{"active"}, + }) + + entitlements, err := pdp.GetEntitlements(ctx, []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + s.Contains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectActive) + s.NotContains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedArchivedValue) + }) +} + +// Test_GetEntitlements_DeactivatedValue asserts deactivated values never surface as entitlements. +func (s *PDPTestSuite) Test_GetEntitlements_DeactivatedValue() { + ctx := s.T().Context() + + attr := deactivationProjectAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedProjectActive, "active", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"active"}, nil), + createSimpleSubjectMapping(testDeactivatedProjectInactive, "inactive", + []*policy.Action{testActionRead}, ".properties.project[]", []string{"inactive"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-both-projects", map[string]interface{}{ + "project": []interface{}{"active", "inactive"}, + }) + + entitlements, err := pdp.GetEntitlements(ctx, []*entityresolutionV2.EntityRepresentation{entity}, nil, false) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + s.Contains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectActive) + s.NotContains(entitlements[0].GetActionsPerAttributeValueFqn(), testDeactivatedProjectInactive) +} diff --git a/tests-bdd/features/deactivated-attribute-values.feature b/tests-bdd/features/deactivated-attribute-values.feature index afe6d983d7..de4a9882ac 100644 --- a/tests-bdd/features/deactivated-attribute-values.feature +++ b/tests-bdd/features/deactivated-attribute-values.feature @@ -27,6 +27,7 @@ Feature: Deactivated attribute values deny decrypt | name | value | | department | ["engineering"] | | classification | ["confidential"] | + # Borrowed only for allow_dynamic_value_mappings, which forces the full-policy PDP. And a default local platform with platform template "cukes/resources/platform.dynamic_value_mappings.template" And a user token for "alice" stored as "alice_tok" diff --git a/tests-bdd/features/direct-entitlements.feature b/tests-bdd/features/direct-entitlements.feature index 932580a9df..db0a55433f 100644 --- a/tests-bdd/features/direct-entitlements.feature +++ b/tests-bdd/features/direct-entitlements.feature @@ -78,14 +78,16 @@ Feature: Direct entitlements decisioning # Deactivating a definition does not cascade to its values, so the value is still active here. # The full entitlement policy load drops deactivated definitions outright, so the resource FQN - # resolves to nothing and the decision fails closed as NOT_FOUND rather than as a DENY. + # is unknown; GetDecision treats an unknown FQN as a per-resource deny rather than a request + # error, so the response is successful and the decision is DENY. Scenario: Direct entitlement denies a value whose definition is deactivated Given there is a claims subject entity referenced as "alice" with direct entitlements: | attribute_value_fqn | actions | | https://example.com/attr/department/value/eng | read | And I deactivate the attribute definition "https://example.com/attr/department" When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng" - Then the response should be unsuccessful + Then the response should be successful + And I should get a "DENY" decision response Scenario: Subject mapping and direct entitlement together satisfy an ALL_OF resource Given there is a claims subject entity referenced as "alice" with direct entitlements: From 5944ebf4f3c837572c7d8359d4d4c53a5344276b Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 4 Sep 2026 18:18:56 -0400 Subject: [PATCH 5/5] address codetabbit comments --- .../access/v2/pdp_deactivated_test.go | 76 +++++++++---------- service/internal/access/v2/pdp_test.go | 3 +- .../features/direct-entitlements.feature | 2 +- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/service/internal/access/v2/pdp_deactivated_test.go b/service/internal/access/v2/pdp_deactivated_test.go index 48cca052b1..00758aab90 100644 --- a/service/internal/access/v2/pdp_deactivated_test.go +++ b/service/internal/access/v2/pdp_deactivated_test.go @@ -18,9 +18,10 @@ var ( testDeactivatedProjectActive = createAttrValueFQN(deactivatedTestNamespace, "project", "active") testDeactivatedProjectInactive = createAttrValueFQN(deactivatedTestNamespace, "project", "inactive") - testDeactivatedClearanceFQN = createAttrFQN(deactivatedTestNamespace, "clearance") - testDeactivatedClearanceHighInactive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "high") - testDeactivatedClearanceLowActive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "low") + testDeactivatedClearanceFQN = createAttrFQN(deactivatedTestNamespace, "clearance") + testDeactivatedClearanceHighInactive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "high") + testDeactivatedClearanceLowActive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "low") + testDeactivatedClearanceLowestInactive = createAttrValueFQN(deactivatedTestNamespace, "clearance", "lowest") testDeactivatedArchivedFQN = createAttrFQN(deactivatedTestNamespace, "archived") testDeactivatedArchivedValue = createAttrValueFQN(deactivatedTestNamespace, "archived", "value1") @@ -40,7 +41,8 @@ func deactivationProjectAttr() *policy.Attribute { } } -// deactivationClearanceAttr is a HIERARCHY definition whose highest value is deactivated. +// deactivationClearanceAttr is a HIERARCHY definition, highest first, with a deactivated value +// both above and below the active one. func deactivationClearanceAttr() *policy.Attribute { return &policy.Attribute{ Fqn: testDeactivatedClearanceFQN, @@ -49,6 +51,7 @@ func deactivationClearanceAttr() *policy.Attribute { Values: []*policy.Value{ {Fqn: testDeactivatedClearanceHighInactive, Value: "high", Active: wrapperspb.Bool(false)}, {Fqn: testDeactivatedClearanceLowActive, Value: "low", Active: wrapperspb.Bool(true)}, + {Fqn: testDeactivatedClearanceLowestInactive, Value: "lowest", Active: wrapperspb.Bool(false)}, }, } } @@ -194,40 +197,9 @@ func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_DynamicValueMappings() }) } -// Test_GetDecision_DeactivatedValue_DirectEntitlements covers the direct-entitlement path where -// the entitled value FQN is carried on the entity representation rather than in policy. -func (s *PDPTestSuite) Test_GetDecision_DeactivatedValue_DirectEntitlements() { - ctx := s.T().Context() - - attr := deactivationProjectAttr() - pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, []*policy.SubjectMapping{}, nil, true, false) - s.Require().NoError(err) - - entity := &entityresolutionV2.EntityRepresentation{ - OriginalId: "entity-direct", - DirectEntitlements: []*entityresolutionV2.DirectEntitlement{ - {AttributeValueFqn: testDeactivatedProjectActive, Actions: []string{testActionRead.GetName()}}, - {AttributeValueFqn: testDeactivatedProjectInactive, Actions: []string{testActionRead.GetName()}}, - }, - } - - s.Run("direct entitlement does not entitle the deactivated value", func() { - decision, entitlements, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ - createAttributeValueResource(testDeactivatedProjectInactive, testDeactivatedProjectInactive), - }) - s.Require().NoError(err) - s.False(decision.AllPermitted) - s.NotContains(entitlements, testDeactivatedProjectInactive) - }) - - s.Run("direct entitlement still entitles the active value", func() { - decision, _, err := pdp.GetDecision(ctx, entity, testActionRead, []*authz.Resource{ - createAttributeValueResource(testDeactivatedProjectActive, testDeactivatedProjectActive), - }) - s.Require().NoError(err) - s.True(decision.AllPermitted) - }) -} +// Deactivated values on the direct-entitlement path are covered by the deactivation subtests of +// Test_GetDecision_DirectEntitlements in pdp_test.go; only the deactivated-definition case below +// is unique to this file. // Test_GetDecision_DeactivatedValue_RegisteredResources covers registered resources on both sides // of a decision: as the entity being entitled, and as the resource being accessed. @@ -428,6 +400,34 @@ func (s *PDPTestSuite) Test_GetDecision_DeactivatedDefinition() { }) } +// Test_GetEntitlements_ComprehensiveHierarchy_DeactivatedLowerValue asserts the comprehensive +// hierarchy cascade skips deactivated lower values. +func (s *PDPTestSuite) Test_GetEntitlements_ComprehensiveHierarchy_DeactivatedLowerValue() { + ctx := s.T().Context() + + attr := deactivationClearanceAttr() + subjectMappings := []*policy.SubjectMapping{ + createSimpleSubjectMapping(testDeactivatedClearanceLowActive, "low", + []*policy.Action{testActionRead}, ".properties.clearance", []string{"low"}, nil), + } + + pdp, err := NewPolicyDecisionPoint(ctx, s.logger, []*policy.Attribute{attr}, subjectMappings, nil, false, false) + s.Require().NoError(err) + + entity := s.createEntityWithProps("entity-low-clearance", map[string]interface{}{ + "clearance": "low", + }) + + entitlements, err := pdp.GetEntitlements(ctx, []*entityresolutionV2.EntityRepresentation{entity}, nil, true) + s.Require().NoError(err) + s.Require().Len(entitlements, 1) + + perValueFQN := entitlements[0].GetActionsPerAttributeValueFqn() + s.Contains(perValueFQN, testDeactivatedClearanceLowActive) + s.NotContains(perValueFQN, testDeactivatedClearanceLowestInactive, + "comprehensive hierarchy must not cascade entitlement into a deactivated lower value") +} + // Test_GetEntitlements_DeactivatedValue asserts deactivated values never surface as entitlements. func (s *PDPTestSuite) Test_GetEntitlements_DeactivatedValue() { ctx := s.T().Context() diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 5c8700cf87..6eeaaa65b5 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -4209,13 +4209,14 @@ func (s *PDPTestSuite) Test_GetDecision_DirectEntitlements() { }, } - decision, _, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ + decision, entitlements, err := pdp.GetDecision(ctx, entityRep, testActionCreate, []*authz.Resource{ createAttributeValueResource(attr3InactiveValueFQN, attr3InactiveValueFQN), }) s.Require().NoError(err) s.Require().NotNil(decision) s.False(decision.AllPermitted, "deactivated attribute value must not be entitled by a direct entitlement") + s.NotContains(entitlements, attr3InactiveValueFQN) s.assertAllDecisionResults(decision, map[string]bool{ attr3InactiveValueFQN: false, }) diff --git a/tests-bdd/features/direct-entitlements.feature b/tests-bdd/features/direct-entitlements.feature index db0a55433f..f4e92479c9 100644 --- a/tests-bdd/features/direct-entitlements.feature +++ b/tests-bdd/features/direct-entitlements.feature @@ -80,7 +80,7 @@ Feature: Direct entitlements decisioning # The full entitlement policy load drops deactivated definitions outright, so the resource FQN # is unknown; GetDecision treats an unknown FQN as a per-resource deny rather than a request # error, so the response is successful and the decision is DENY. - Scenario: Direct entitlement denies a value whose definition is deactivated + Scenario: Direct entitlement denies a value whose definition deactivation dropped it from policy Given there is a claims subject entity referenced as "alice" with direct entitlements: | attribute_value_fqn | actions | | https://example.com/attr/department/value/eng | read |