From 3263744d63585e96ec1b51683d88b7161a769d53 Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Tue, 21 Jul 2026 14:51:14 -0400 Subject: [PATCH 1/8] test(ers): add BDD cukes for multi-strategy ERS (Claims + LDAP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add end-to-end BDD tests that validate multi-strategy entity resolution through the full gRPC stack (SDK → Connect RPC → platform → ERS → LDAP), catching serialization bugs at the gRPC/structpb boundary that direct-call integration tests miss. - 3 scenarios: PERMIT for matching department, DENY for non-matching, PERMIT for different department value - LDAP testcontainer step using osixia/openldap with existing LDIF fixtures - Multi-strategy platform template with Claims + LDAP providers - LDAPPort propagation in @stateless scenario context DSPX-4100 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho --- tests-bdd/cukes/glue_platform.go | 2 + .../platform.multi_strategy_ers.template | 154 ++++++++++++++++++ tests-bdd/cukes/steps_ldap.go | 102 ++++++++++++ tests-bdd/cukes/steps_localplatform.go | 1 + tests-bdd/features/multi-strategy-ers.feature | 70 ++++++++ tests-bdd/platform_test.go | 1 + 6 files changed, 330 insertions(+) create mode 100644 tests-bdd/cukes/resources/platform.multi_strategy_ers.template create mode 100644 tests-bdd/cukes/steps_ldap.go create mode 100644 tests-bdd/features/multi-strategy-ers.feature diff --git a/tests-bdd/cukes/glue_platform.go b/tests-bdd/cukes/glue_platform.go index 5c2b3ed09c..e851841348 100644 --- a/tests-bdd/cukes/glue_platform.go +++ b/tests-bdd/cukes/glue_platform.go @@ -70,6 +70,7 @@ type LocalDevScenarioOptions struct { InsecureSkipVerifyConn bool DatabaseName string PlatformPort int + LDAPPort int } func (d *DockerComposeLogger) Printf(format string, v ...interface{}) { @@ -135,6 +136,7 @@ func (c *PlatformTestSuiteContext) InitializeScenario(scenarioContext *godog.Sce KeycloakRealm: trackedScenarioContext.ScenarioOptions.KeycloakRealm, PlatformEndpoint: trackedScenarioContext.ScenarioOptions.PlatformEndpoint, InsecureSkipVerifyConn: trackedScenarioContext.ScenarioOptions.InsecureSkipVerifyConn, + LDAPPort: trackedScenarioContext.ScenarioOptions.LDAPPort, }, TestSuiteContext: c, } diff --git a/tests-bdd/cukes/resources/platform.multi_strategy_ers.template b/tests-bdd/cukes/resources/platform.multi_strategy_ers.template new file mode 100644 index 0000000000..f5e880e5d3 --- /dev/null +++ b/tests-bdd/cukes/resources/platform.multi_strategy_ers.template @@ -0,0 +1,154 @@ +# Multi-strategy ERS platform template for BDD tests. +# Based on platform.template with entityresolution changed to multi-strategy mode +# (Claims + LDAP). Requires an LDAP testcontainer started by the "an LDAP directory +# with test users" step definition — the dynamic port is templated via {{ .ldapPort }}. +logger: + level: debug + type: text + output: stderr +mode: all +db: + host: {{ .pgHost }} + port: {{ .pgPort }} + database: {{ .pgDatabase }} + user: postgres + password: changeme + schema: otdf +services: + kas: + registered_kas_uri: http://{{ .hostname }}:{{ .platformPort }} + preview: + ec_tdf_enabled: false + key_management: false + root_key: a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1 + 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: + mode: "multi-strategy" + failure_strategy: "continue" + providers: + jwt_claims: + type: claims + connection: {} + ldap_directory: + type: ldap + connection: + host: {{ .hostname }} + port: {{ .ldapPort }} + use_tls: false + bind_dn: "cn=admin,dc=opentdf,dc=test" + bind_password: "admin123" + mapping_strategies: + - name: claims_passthrough + provider: jwt_claims + entity_type: subject + conditions: + jwt_claims: + - claim: userName + operator: exists + output_mapping: + - source_claim: userName + claim_name: username + - name: ldap_by_username + provider: ldap_directory + entity_type: subject + conditions: + jwt_claims: + - claim: userName + operator: exists + ldap_search: + base_dn: "ou=users,dc=opentdf,dc=test" + filter: "(&(objectClass=inetOrgPerson)(uid={username}))" + scope: subtree + attributes: ["uid", "mail", "departmentNumber"] + input_mapping: + - jwt_claim: userName + parameter: username + output_mapping: + - source_attribute: departmentNumber + claim_name: department + - source_attribute: mail + claim_name: email + - source_attribute: uid + claim_name: username +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: + username_claim: + groups_claim: + client_id_claim: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + csv: + model: + trace: + enabled: false + provider: + name: file + file: + path: "./traces/traces.log" + prettyPrint: true + maxSize: 50 + maxBackups: 5 + maxAge: 14 + compress: true + cors: + enabled: true + allowedorigins: + - "*" + allowedmethods: + - GET + - POST + - PATCH + - PUT + - DELETE + - OPTIONS + allowedheaders: + - Accept + - Accept-Encoding + - Authorization + - Connect-Protocol-Version + - Content-Length + - Content-Type + - Dpop + - X-CSRF-Token + - X-Requested-With + - X-Rewrap-Additional-Context + exposedheaders: + - Link + allowcredentials: true + maxage: 3600 + grpc: + reflectionEnabled: true + 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_ldap.go b/tests-bdd/cukes/steps_ldap.go new file mode 100644 index 0000000000..97620d78e4 --- /dev/null +++ b/tests-bdd/cukes/steps_ldap.go @@ -0,0 +1,102 @@ +package cukes + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "time" + + "github.com/cucumber/godog" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +type LDAPStepDefinitions struct { + PlatformCukesContext *PlatformTestSuiteContext +} + +func (s *LDAPStepDefinitions) anLDAPDirectoryWithTestUsers(ctx context.Context) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + logger := scenarioContext.TestSuiteContext.Logger + + // In @stateless mode, reuse the LDAP container from the first scenario + if scenarioContext.Stateless && scenarioContext.ScenarioOptions.LDAPPort > 0 { + logger.Info("reusing existing LDAP testcontainer", slog.Int("port", scenarioContext.ScenarioOptions.LDAPPort)) + return ctx, nil + } + + glue, ok := (*scenarioContext.TestSuiteContext.PlatformGlue).(*LocalDevPlatformGlue) + if !ok { + return ctx, fmt.Errorf("platform glue is not LocalDevPlatformGlue") + } + projectDir := glue.Options.ProjectDir + + ldapDataDir := filepath.Join(projectDir, "service", "entityresolution", "integration", "ldap_test_data") + ouFile := filepath.Join(ldapDataDir, "01_organizational_units.ldif") + usersFile := filepath.Join(ldapDataDir, "02_test_users.ldif") + + logger.Info("starting LDAP testcontainer", slog.String("ldap_data_dir", ldapDataDir)) + + containerRequest := testcontainers.ContainerRequest{ + Image: "osixia/openldap:1.5.0", + ExposedPorts: []string{"389/tcp"}, + Env: map[string]string{ + "LDAP_ORGANISATION": "OpenTDF Test", + "LDAP_DOMAIN": "opentdf.test", + "LDAP_ADMIN_PASSWORD": "admin123", + }, + Cmd: []string{"--copy-service"}, + Files: []testcontainers.ContainerFile{ + { + HostFilePath: ouFile, + ContainerFilePath: "/container/service/slapd/assets/config/bootstrap/ldif/custom/01_organizational_units.ldif", + FileMode: 0o644, + }, + { + HostFilePath: usersFile, + ContainerFilePath: "/container/service/slapd/assets/config/bootstrap/ldif/custom/02_test_users.ldif", + FileMode: 0o644, + }, + }, + WaitingFor: wait.ForLog("slapd starting").WithStartupTimeout(60 * time.Second), + } + + ldapContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: containerRequest, + Started: true, + }) + if err != nil { + return ctx, fmt.Errorf("failed to start LDAP container: %w", err) + } + + host, err := ldapContainer.Host(ctx) + if err != nil { + return ctx, fmt.Errorf("failed to get LDAP container host: %w", err) + } + + mappedPort, err := ldapContainer.MappedPort(ctx, "389") + if err != nil { + return ctx, fmt.Errorf("failed to get LDAP container port: %w", err) + } + + scenarioContext.ScenarioOptions.LDAPPort = int(mappedPort.Num()) + + logger.Info("LDAP testcontainer started", + slog.String("host", host), + slog.Int("port", scenarioContext.ScenarioOptions.LDAPPort)) + + scenarioContext.RegisterPlatformShutdownHook(func() error { + logger.Info("terminating LDAP testcontainer") + return ldapContainer.Terminate(context.WithoutCancel(ctx)) + }) + + return ctx, nil +} + +func RegisterLDAPStepDefinitions(ctx *godog.ScenarioContext, x *PlatformTestSuiteContext) { + steps := &LDAPStepDefinitions{ + PlatformCukesContext: x, + } + ctx.Step(`^an LDAP directory with test users$`, steps.anLDAPDirectoryWithTestUsers) +} diff --git a/tests-bdd/cukes/steps_localplatform.go b/tests-bdd/cukes/steps_localplatform.go index e9fb1b8882..9fbb0e83f6 100644 --- a/tests-bdd/cukes/steps_localplatform.go +++ b/tests-bdd/cukes/steps_localplatform.go @@ -557,6 +557,7 @@ func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *Loca "pgHost": pgHost, "platformKeysDir": platformKeysDir, "authRealm": scenarioOptions.KeycloakRealm, + "ldapPort": scenarioOptions.LDAPPort, }); err != nil { return tempFileName, err } diff --git a/tests-bdd/features/multi-strategy-ers.feature b/tests-bdd/features/multi-strategy-ers.feature new file mode 100644 index 0000000000..027ea707e8 --- /dev/null +++ b/tests-bdd/features/multi-strategy-ers.feature @@ -0,0 +1,70 @@ +@multi-strategy-ers @stateless +Feature: Multi-strategy ERS entity resolution (Claims + LDAP) + Validate that multi-strategy ERS resolves entities from LDAP through the full + gRPC stack (SDK -> Connect RPC -> platform -> ERS -> LDAP). This catches + serialization bugs at the gRPC/structpb boundary that direct-call integration + tests miss. + + Background: + Given an LDAP directory with test users + And a local platform with platform template "cukes/resources/platform.multi_strategy_ers.template" and keycloak template "cukes/resources/keycloak_base.template" + + Scenario: LDAP-resolved engineering user gets PERMIT + Given I submit a request to create a namespace with name "eng-permit.test" and reference id "ns_eng_permit" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_eng_permit | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | engineering | + And a subject set referenced as "ss_eng" containing the condition groups "cg_eng" + And I send a request to create a subject condition set referenced as "scs_eng" containing subject sets "ss_eng" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_eng | https://eng-permit.test/attr/department/value/engineering | scs_eng | read | | + Then the response should be successful + Given there is a "user_name" subject entity with value "alice" and referenced as "alice" + When I send a decision request for entity chain "alice" for "read" action on resource "https://eng-permit.test/attr/department/value/engineering" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: LDAP-resolved marketing user gets DENY for engineering resource + Given I submit a request to create a namespace with name "eng-deny.test" and reference id "ns_eng_deny" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_eng_deny | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_eng2" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | engineering | + And a subject set referenced as "ss_eng2" containing the condition groups "cg_eng2" + And I send a request to create a subject condition set referenced as "scs_eng2" containing subject sets "ss_eng2" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_eng2 | https://eng-deny.test/attr/department/value/engineering | scs_eng2 | read | | + Then the response should be successful + Given there is a "user_name" subject entity with value "bob" and referenced as "bob" + When I send a decision request for entity chain "bob" for "read" action on resource "https://eng-deny.test/attr/department/value/engineering" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: LDAP-resolved security user gets PERMIT for security resource + Given I submit a request to create a namespace with name "sec-permit.test" and reference id "ns_sec_permit" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_sec_permit | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_sec" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | security | + And a subject set referenced as "ss_sec" containing the condition groups "cg_sec" + And I send a request to create a subject condition set referenced as "scs_sec" containing subject sets "ss_sec" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_sec | https://sec-permit.test/attr/department/value/security | scs_sec | read | | + Then the response should be successful + Given there is a "user_name" subject entity with value "charlie" and referenced as "charlie" + When I send a decision request for entity chain "charlie" for "read" action on resource "https://sec-permit.test/attr/department/value/security" + Then the response should be successful + And I should get a "PERMIT" decision response diff --git a/tests-bdd/platform_test.go b/tests-bdd/platform_test.go index 34987b5f5a..bbb0a3af50 100644 --- a/tests-bdd/platform_test.go +++ b/tests-bdd/platform_test.go @@ -114,6 +114,7 @@ func runTests() int { cukes.RegisterObligationsStepDefinitions(ctx, platformCukesContext) cukes.RegisterKasRegistryStepDefinitions(ctx) cukes.RegisterEncryptionStepDefinitions(ctx) + cukes.RegisterLDAPStepDefinitions(ctx, platformCukesContext) platformCukesContext.InitializeScenario(ctx) }, Options: &opts, From 032476b7f3d5f56f4d8884edee3de55c5acc3958 Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Tue, 21 Jul 2026 15:01:31 -0400 Subject: [PATCH 2/8] docs(ers): add multi-strategy ERS testing section to BDD README Add getting started guide, architecture diagram, key files reference, and implementation notes for the multi-strategy ERS BDD tests. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho --- tests-bdd/README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests-bdd/README.md b/tests-bdd/README.md index b82dddcabc..249d613cae 100644 --- a/tests-bdd/README.md +++ b/tests-bdd/README.md @@ -302,6 +302,94 @@ The extension uses the configuration in `.vscode/settings.json` to locate your f - After installing the extension, you may need to reload VSCode or the extension for autocompletion to work. - If autocompletion is not working for specific steps, make sure that the steps are defined properly. Run the tests locally and ensure that no undefined steps are found. +### Multi-Strategy ERS Testing (Claims + LDAP) + +The `@multi-strategy-ers` scenarios validate that multi-strategy entity resolution works end-to-end through the full gRPC stack: **SDK → Connect RPC → platform server → ERS → LDAP**. + +These tests exist because direct-call Go integration tests (in `service/entityresolution/integration/`) bypass the gRPC/Connect RPC serialization layer. Bugs like structpb coercion (#3645) and pgx driver issues (#3672) only manifest at the serialization boundary — invisible to unit tests but caught by these BDD tests. + +#### What's tested + +| Scenario | User | LDAP department | Resource | Expected | +|----------|------|----------------|----------|----------| +| 1 | alice | engineering | engineering | PERMIT | +| 2 | bob | marketing | engineering | DENY | +| 3 | charlie | security | security | PERMIT | + +Each scenario creates its own namespace, attribute definitions, and subject mappings, then issues an authorization decision request. The ERS resolves the `user_name` entity against LDAP to retrieve department claims, which are then evaluated against subject mapping conditions. + +#### Architecture + +``` +┌──────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────────────┐ ┌──────────┐ +│ SDK │───▶│ Connect RPC │───▶│ Platform │───▶│ Multi-Strategy │───▶│ LDAP │ +│ (client) │ │ (gRPC) │ │ (server) │ │ ERS (Claims + │ │ (osixia/ │ +│ │◀───│ │◀───│ │◀───│ LDAP providers)│◀───│ openldap)│ +└──────────┘ └─────────────┘ └──────────┘ └─────────────────┘ └──────────┘ +``` + +The multi-strategy ERS config uses two providers: +- **claims_passthrough** — passes through JWT claims (e.g. `userName`) +- **ldap_by_username** — looks up the user in LDAP by `uid`, returns `departmentNumber`, `mail`, `uid` + +#### Getting started + +**Prerequisites:** +- Docker (via Colima or Docker Desktop) +- Go 1.25+ +- JDK (`keytool` required for Keycloak truststore generation) + ```bash + brew install openjdk + ``` + +**1. Set environment variables** (Colima users): +```bash +export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock" +export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock +export TESTCONTAINERS_RYUK_DISABLED=true +export PLATFORM_IMAGE=DEBUG +``` + +Add `keytool` to PATH if using brew-installed OpenJDK: +```bash +export PATH="/opt/homebrew/opt/openjdk/bin:$PATH" +``` + +**2. Run the multi-strategy ERS tests:** +```bash +go test ./tests-bdd/ -v --tags=cukes --godog.tags=@multi-strategy-ers --count=1 +``` + +With console logging for debugging: +```bash +CUKES_LOG_HANDLER=console go test ./tests-bdd/ -v --tags=cukes \ + --godog.tags=@multi-strategy-ers --godog.format=pretty --count=1 +``` + +**3. Verify output:** +All 3 scenarios should pass (42 steps, 0 failures). You'll see LDAP testcontainer startup, platform service registration, and authorization decision logs. + +#### Key files + +| File | Purpose | +|------|---------| +| [`features/multi-strategy-ers.feature`](features/multi-strategy-ers.feature) | Gherkin scenarios (3 scenarios, `@stateless`) | +| [`cukes/steps_ldap.go`](cukes/steps_ldap.go) | LDAP testcontainer step definition | +| [`cukes/resources/platform.multi_strategy_ers.template`](cukes/resources/platform.multi_strategy_ers.template) | Platform config with multi-strategy ERS | + +#### How it works + +1. **LDAP testcontainer** starts `osixia/openldap:1.5.0` with LDIF fixtures from `service/entityresolution/integration/ldap_test_data/` (8 test users with distinct department values) +2. **Platform template** configures ERS in `multi-strategy` mode with `failure_strategy: "continue"` and two providers (Claims + LDAP) +3. **Each scenario** creates a namespace, department attribute (anyOf rule), subject mapping with `.department` selector, and then issues an authorization decision for a `user_name` entity +4. The platform resolves the entity through ERS → LDAP, gets department claims, evaluates subject mappings, and returns PERMIT or DENY + +#### Notes + +- The feature uses `@stateless` so the platform instance and LDAP container are shared across all scenarios within the feature. Each scenario uses a unique namespace to avoid data conflicts. +- LDAP test data lives in `service/entityresolution/integration/ldap_test_data/` and is shared with the Go integration tests. +- The `--copy-service` flag is used with the LDAP container to avoid macOS `sed -i` compatibility issues with bind mounts. + ## TODO - Improve execution time for platform testing - Remove keycloak with wiremock/mock or mock From 5ad690dd77fb360947355c5d6a7c23c0b24842d2 Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Tue, 21 Jul 2026 15:13:19 -0400 Subject: [PATCH 3/8] fix(test): clean up LDAP container on setup failure Ensure the LDAP testcontainer is terminated if Host() or MappedPort() fail after the container has started, preventing leaked containers. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho --- tests-bdd/cukes/steps_ldap.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests-bdd/cukes/steps_ldap.go b/tests-bdd/cukes/steps_ldap.go index 97620d78e4..05b52caa12 100644 --- a/tests-bdd/cukes/steps_ldap.go +++ b/tests-bdd/cukes/steps_ldap.go @@ -67,9 +67,19 @@ func (s *LDAPStepDefinitions) anLDAPDirectoryWithTestUsers(ctx context.Context) Started: true, }) if err != nil { + if ldapContainer != nil { + _ = ldapContainer.Terminate(context.WithoutCancel(ctx)) + } return ctx, fmt.Errorf("failed to start LDAP container: %w", err) } + setupComplete := false + defer func() { + if !setupComplete { + _ = ldapContainer.Terminate(context.WithoutCancel(ctx)) + } + }() + host, err := ldapContainer.Host(ctx) if err != nil { return ctx, fmt.Errorf("failed to get LDAP container host: %w", err) @@ -90,6 +100,7 @@ func (s *LDAPStepDefinitions) anLDAPDirectoryWithTestUsers(ctx context.Context) logger.Info("terminating LDAP testcontainer") return ldapContainer.Terminate(context.WithoutCancel(ctx)) }) + setupComplete = true return ctx, nil } From fbcb6b28cae108eb2ba6a0243c843bfaa19daaf2 Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Tue, 21 Jul 2026 15:22:23 -0400 Subject: [PATCH 4/8] chore(ers): fix golangci-lint issues in steps_ldap.go Extract magic numbers to constants and use errors.New for static error strings to satisfy mnd and perfsprint linters. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho --- tests-bdd/cukes/steps_ldap.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests-bdd/cukes/steps_ldap.go b/tests-bdd/cukes/steps_ldap.go index 05b52caa12..b1e92fb6b4 100644 --- a/tests-bdd/cukes/steps_ldap.go +++ b/tests-bdd/cukes/steps_ldap.go @@ -2,6 +2,7 @@ package cukes import ( "context" + "errors" "fmt" "log/slog" "path/filepath" @@ -12,6 +13,11 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) +const ( + ldapFileMode = 0o644 + ldapStartupTimeout = 60 * time.Second +) + type LDAPStepDefinitions struct { PlatformCukesContext *PlatformTestSuiteContext } @@ -28,7 +34,7 @@ func (s *LDAPStepDefinitions) anLDAPDirectoryWithTestUsers(ctx context.Context) glue, ok := (*scenarioContext.TestSuiteContext.PlatformGlue).(*LocalDevPlatformGlue) if !ok { - return ctx, fmt.Errorf("platform glue is not LocalDevPlatformGlue") + return ctx, errors.New("platform glue is not LocalDevPlatformGlue") } projectDir := glue.Options.ProjectDir @@ -51,15 +57,15 @@ func (s *LDAPStepDefinitions) anLDAPDirectoryWithTestUsers(ctx context.Context) { HostFilePath: ouFile, ContainerFilePath: "/container/service/slapd/assets/config/bootstrap/ldif/custom/01_organizational_units.ldif", - FileMode: 0o644, + FileMode: ldapFileMode, }, { HostFilePath: usersFile, ContainerFilePath: "/container/service/slapd/assets/config/bootstrap/ldif/custom/02_test_users.ldif", - FileMode: 0o644, + FileMode: ldapFileMode, }, }, - WaitingFor: wait.ForLog("slapd starting").WithStartupTimeout(60 * time.Second), + WaitingFor: wait.ForLog("slapd starting").WithStartupTimeout(ldapStartupTimeout), } ldapContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ From 91dd337fd4fe3d11b7c5a99973acf4107f733586 Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Wed, 22 Jul 2026 14:31:09 -0400 Subject: [PATCH 5/8] chore(docs): fix markdown lint in multi-strategy ERS README section Add language tag to architecture diagram fence and blank lines before code blocks to satisfy markdownlint. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho --- tests-bdd/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests-bdd/README.md b/tests-bdd/README.md index 249d613cae..ef485d8731 100644 --- a/tests-bdd/README.md +++ b/tests-bdd/README.md @@ -320,7 +320,7 @@ Each scenario creates its own namespace, attribute definitions, and subject mapp #### Architecture -``` +```text ┌──────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────────────┐ ┌──────────┐ │ SDK │───▶│ Connect RPC │───▶│ Platform │───▶│ Multi-Strategy │───▶│ LDAP │ │ (client) │ │ (gRPC) │ │ (server) │ │ ERS (Claims + │ │ (osixia/ │ @@ -338,11 +338,13 @@ The multi-strategy ERS config uses two providers: - Docker (via Colima or Docker Desktop) - Go 1.25+ - JDK (`keytool` required for Keycloak truststore generation) + ```bash brew install openjdk ``` **1. Set environment variables** (Colima users): + ```bash export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock" export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock @@ -351,16 +353,19 @@ export PLATFORM_IMAGE=DEBUG ``` Add `keytool` to PATH if using brew-installed OpenJDK: + ```bash export PATH="/opt/homebrew/opt/openjdk/bin:$PATH" ``` **2. Run the multi-strategy ERS tests:** + ```bash go test ./tests-bdd/ -v --tags=cukes --godog.tags=@multi-strategy-ers --count=1 ``` With console logging for debugging: + ```bash CUKES_LOG_HANDLER=console go test ./tests-bdd/ -v --tags=cukes \ --godog.tags=@multi-strategy-ers --godog.format=pretty --count=1 From 562e3c20824514d7ae20a57ffa51661654638dac Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Thu, 23 Jul 2026 15:11:43 -0400 Subject: [PATCH 6/8] chore(ers): inline ERS config in feature file with DocString steps Replace the external platform.multi_strategy_ers.template with inline ERS configuration step definitions using Gherkin DocStrings. Mapping strategies, providers, and conditions are now visible directly in the feature file, making tests self-documenting per review feedback. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho --- tests-bdd/README.md | 6 +- .../platform.multi_strategy_ers.template | 154 ---------------- tests-bdd/cukes/steps_ers.go | 165 ++++++++++++++++++ tests-bdd/cukes/steps_localplatform.go | 29 ++- tests-bdd/features/multi-strategy-ers.feature | 39 ++++- tests-bdd/platform_test.go | 1 + 6 files changed, 233 insertions(+), 161 deletions(-) delete mode 100644 tests-bdd/cukes/resources/platform.multi_strategy_ers.template create mode 100644 tests-bdd/cukes/steps_ers.go diff --git a/tests-bdd/README.md b/tests-bdd/README.md index ef485d8731..64dce7cb97 100644 --- a/tests-bdd/README.md +++ b/tests-bdd/README.md @@ -372,7 +372,7 @@ CUKES_LOG_HANDLER=console go test ./tests-bdd/ -v --tags=cukes \ ``` **3. Verify output:** -All 3 scenarios should pass (42 steps, 0 failures). You'll see LDAP testcontainer startup, platform service registration, and authorization decision logs. +All 3 scenarios should pass (57 steps, 0 failures). You'll see LDAP testcontainer startup, platform service registration, and authorization decision logs. #### Key files @@ -380,12 +380,12 @@ All 3 scenarios should pass (42 steps, 0 failures). You'll see LDAP testcontaine |------|---------| | [`features/multi-strategy-ers.feature`](features/multi-strategy-ers.feature) | Gherkin scenarios (3 scenarios, `@stateless`) | | [`cukes/steps_ldap.go`](cukes/steps_ldap.go) | LDAP testcontainer step definition | -| [`cukes/resources/platform.multi_strategy_ers.template`](cukes/resources/platform.multi_strategy_ers.template) | Platform config with multi-strategy ERS | +| [`cukes/steps_ers.go`](cukes/steps_ers.go) | Inline ERS configuration step definitions (DocString-based) | #### How it works 1. **LDAP testcontainer** starts `osixia/openldap:1.5.0` with LDIF fixtures from `service/entityresolution/integration/ldap_test_data/` (8 test users with distinct department values) -2. **Platform template** configures ERS in `multi-strategy` mode with `failure_strategy: "continue"` and two providers (Claims + LDAP) +2. **Inline ERS configuration** in the feature file's Background defines providers (Claims + LDAP) and mapping strategies with DocString YAML, making the full config visible without referencing external template files 3. **Each scenario** creates a namespace, department attribute (anyOf rule), subject mapping with `.department` selector, and then issues an authorization decision for a `user_name` entity 4. The platform resolves the entity through ERS → LDAP, gets department claims, evaluates subject mappings, and returns PERMIT or DENY diff --git a/tests-bdd/cukes/resources/platform.multi_strategy_ers.template b/tests-bdd/cukes/resources/platform.multi_strategy_ers.template deleted file mode 100644 index f5e880e5d3..0000000000 --- a/tests-bdd/cukes/resources/platform.multi_strategy_ers.template +++ /dev/null @@ -1,154 +0,0 @@ -# Multi-strategy ERS platform template for BDD tests. -# Based on platform.template with entityresolution changed to multi-strategy mode -# (Claims + LDAP). Requires an LDAP testcontainer started by the "an LDAP directory -# with test users" step definition — the dynamic port is templated via {{ .ldapPort }}. -logger: - level: debug - type: text - output: stderr -mode: all -db: - host: {{ .pgHost }} - port: {{ .pgPort }} - database: {{ .pgDatabase }} - user: postgres - password: changeme - schema: otdf -services: - kas: - registered_kas_uri: http://{{ .hostname }}:{{ .platformPort }} - preview: - ec_tdf_enabled: false - key_management: false - root_key: a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1 - 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: - mode: "multi-strategy" - failure_strategy: "continue" - providers: - jwt_claims: - type: claims - connection: {} - ldap_directory: - type: ldap - connection: - host: {{ .hostname }} - port: {{ .ldapPort }} - use_tls: false - bind_dn: "cn=admin,dc=opentdf,dc=test" - bind_password: "admin123" - mapping_strategies: - - name: claims_passthrough - provider: jwt_claims - entity_type: subject - conditions: - jwt_claims: - - claim: userName - operator: exists - output_mapping: - - source_claim: userName - claim_name: username - - name: ldap_by_username - provider: ldap_directory - entity_type: subject - conditions: - jwt_claims: - - claim: userName - operator: exists - ldap_search: - base_dn: "ou=users,dc=opentdf,dc=test" - filter: "(&(objectClass=inetOrgPerson)(uid={username}))" - scope: subtree - attributes: ["uid", "mail", "departmentNumber"] - input_mapping: - - jwt_claim: userName - parameter: username - output_mapping: - - source_attribute: departmentNumber - claim_name: department - - source_attribute: mail - claim_name: email - - source_attribute: uid - claim_name: username -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: - username_claim: - groups_claim: - client_id_claim: - extension: | - g, opentdf-admin, role:admin - g, opentdf-standard, role:standard - csv: - model: - trace: - enabled: false - provider: - name: file - file: - path: "./traces/traces.log" - prettyPrint: true - maxSize: 50 - maxBackups: 5 - maxAge: 14 - compress: true - cors: - enabled: true - allowedorigins: - - "*" - allowedmethods: - - GET - - POST - - PATCH - - PUT - - DELETE - - OPTIONS - allowedheaders: - - Accept - - Accept-Encoding - - Authorization - - Connect-Protocol-Version - - Content-Length - - Content-Type - - Dpop - - X-CSRF-Token - - X-Requested-With - - X-Rewrap-Additional-Context - exposedheaders: - - Link - allowcredentials: true - maxage: 3600 - grpc: - reflectionEnabled: true - 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_ers.go b/tests-bdd/cukes/steps_ers.go new file mode 100644 index 0000000000..ec5212398b --- /dev/null +++ b/tests-bdd/cukes/steps_ers.go @@ -0,0 +1,165 @@ +package cukes + +import ( + "context" + "errors" + "fmt" + "text/template" + + "github.com/cucumber/godog" + "gopkg.in/yaml.v2" +) + +const ersConfigKey = "ers_inline_config" + +type ERSInlineConfig struct { + Mode string + FailureStrategy string + Providers map[string]ERSProviderConfig + Strategies []ERSMappingStrategyConfig +} + +type ERSProviderConfig struct { + Type string + AutoWireLDAP bool +} + +type ERSMappingStrategyConfig struct { + Name string + Provider string + RawYAML string +} + +type ERSStepDefinitions struct { + PlatformCukesContext *PlatformTestSuiteContext + PlatformSteps *LocalPlatformStepDefinitions +} + +func getERSConfig(sc *PlatformScenarioContext) (*ERSInlineConfig, error) { + obj := sc.GetObject(ersConfigKey) + if obj == nil { + return nil, errors.New("no ERS configuration found; use 'an ERS configuration' step first") + } + cfg, ok := obj.(*ERSInlineConfig) + if !ok { + return nil, errors.New("invalid ERS configuration object") + } + return cfg, nil +} + +func (s *ERSStepDefinitions) anERSConfiguration(ctx context.Context, mode string, failureStrategy string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + cfg := &ERSInlineConfig{ + Mode: mode, + FailureStrategy: failureStrategy, + Providers: make(map[string]ERSProviderConfig), + } + scenarioContext.RecordObject(ersConfigKey, cfg) + return ctx, nil +} + +func (s *ERSStepDefinitions) anERSProvider(ctx context.Context, name string, providerType string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + cfg, err := getERSConfig(scenarioContext) + if err != nil { + return ctx, err + } + cfg.Providers[name] = ERSProviderConfig{Type: providerType} + return ctx, nil +} + +func (s *ERSStepDefinitions) anERSProviderConnectedToLDAP(ctx context.Context, name string, providerType string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + cfg, err := getERSConfig(scenarioContext) + if err != nil { + return ctx, err + } + cfg.Providers[name] = ERSProviderConfig{ + Type: providerType, + AutoWireLDAP: true, + } + return ctx, nil +} + +func (s *ERSStepDefinitions) anERSMappingStrategy(ctx context.Context, name string, provider string, doc *godog.DocString) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + cfg, err := getERSConfig(scenarioContext) + if err != nil { + return ctx, err + } + cfg.Strategies = append(cfg.Strategies, ERSMappingStrategyConfig{ + Name: name, + Provider: provider, + RawYAML: doc.Content, + }) + return ctx, nil +} + +func (s *ERSStepDefinitions) aLocalPlatformWithInlineERSConfiguration(ctx context.Context) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + cfg, err := getERSConfig(scenarioContext) + if err != nil { + return ctx, err + } + kt := template.Must(template.New("kc").Parse(keycloakBaseTemplate)) + return s.PlatformSteps.commonLocalPlatform(ctx, &platformStartOptions{ + kcProvisionPath: kt, + ersConfig: cfg, + }) +} + +func buildEntityResolutionConfig(cfg *ERSInlineConfig, hostname string, ldapPort int) (map[string]interface{}, error) { + providers := map[string]interface{}{} + for name, p := range cfg.Providers { + provider := map[string]interface{}{ + "type": p.Type, + } + if p.AutoWireLDAP { + provider["connection"] = map[string]interface{}{ + "host": hostname, + "port": ldapPort, + "use_tls": false, + "bind_dn": "cn=admin,dc=opentdf,dc=test", + "bind_password": "admin123", + } + } else { + provider["connection"] = map[string]interface{}{} + } + providers[name] = provider + } + + var strategies []interface{} + for _, s := range cfg.Strategies { + var rawStrategy map[interface{}]interface{} + if err := yaml.Unmarshal([]byte(s.RawYAML), &rawStrategy); err != nil { + return nil, fmt.Errorf("failed to parse mapping strategy %q YAML: %w", s.Name, err) + } + strategy := map[string]interface{}{ + "name": s.Name, + "provider": s.Provider, + } + for k, v := range rawStrategy { + strategy[fmt.Sprintf("%v", k)] = v + } + strategies = append(strategies, strategy) + } + + return map[string]interface{}{ + "mode": cfg.Mode, + "failure_strategy": cfg.FailureStrategy, + "providers": providers, + "mapping_strategies": strategies, + }, nil +} + +func RegisterERSStepDefinitions(ctx *godog.ScenarioContext, x *PlatformTestSuiteContext) { + steps := &ERSStepDefinitions{ + PlatformCukesContext: x, + PlatformSteps: &LocalPlatformStepDefinitions{PlatformCukesContext: x}, + } + ctx.Step(`^an ERS configuration with mode "([^"]*)" and failure strategy "([^"]*)"$`, steps.anERSConfiguration) + ctx.Step(`^an ERS provider "([^"]*)" of type "([^"]*)"$`, steps.anERSProvider) + ctx.Step(`^an ERS provider "([^"]*)" of type "([^"]*)" connected to the LDAP directory$`, steps.anERSProviderConnectedToLDAP) + ctx.Step(`^an ERS mapping strategy "([^"]*)" using provider "([^"]*)"$`, steps.anERSMappingStrategy) + ctx.Step(`^a local platform with inline ERS configuration$`, steps.aLocalPlatformWithInlineERSConfiguration) +} diff --git a/tests-bdd/cukes/steps_localplatform.go b/tests-bdd/cukes/steps_localplatform.go index 9fbb0e83f6..042133d1ef 100644 --- a/tests-bdd/cukes/steps_localplatform.go +++ b/tests-bdd/cukes/steps_localplatform.go @@ -59,6 +59,7 @@ type platformStartOptions struct { platformProvisionPath *string kcProvisionPath *template.Template provisionDefaultPolicy bool + ersConfig *ERSInlineConfig } func (s *LocalPlatformStepDefinitions) aUser(ctx context.Context, username string, email string, attributes *godog.Table) (context.Context, error) { @@ -170,7 +171,7 @@ func (s *LocalPlatformStepDefinitions) commonLocalPlatform(ctx context.Context, if !exists { version = platformImageEnvironmentLocalImage } - platformConfigPath, err := createPlatformConfiguration(localPlatformOptions, scenarioContext.ScenarioOptions, version == debugVersion, options.platformProvisionPath) + platformConfigPath, err := createPlatformConfiguration(localPlatformOptions, scenarioContext.ScenarioOptions, version == debugVersion, options.platformProvisionPath, options.ersConfig) if err != nil { return ctx, err } @@ -530,7 +531,7 @@ func createPlatformComposeConfiguration(options *LocalDevOptions) (string, error } // createPlatformConfiguration generates a platform configuration from a go text template for platform option settings -func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *LocalDevScenarioOptions, devMode bool, platformTemplatePath *string) (string, error) { +func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *LocalDevScenarioOptions, devMode bool, platformTemplatePath *string, ersConfig *ERSInlineConfig) (string, error) { tempFileName := path.Join(options.CukesDir, "opentdf.yaml") platformKeysDir := options.KeysDir pgHost := "localhost" @@ -561,7 +562,29 @@ func createPlatformConfiguration(options *LocalDevOptions, scenarioOptions *Loca }); err != nil { return tempFileName, err } - err := os.WriteFile(tempFileName, strBuffer.Bytes(), os.FileMode(0o755)) //nolint:mnd // mkdir dir + + renderedBytes := strBuffer.Bytes() + if ersConfig != nil { + var configMap map[interface{}]interface{} + if err := yaml.Unmarshal(renderedBytes, &configMap); err != nil { + return tempFileName, fmt.Errorf("failed to parse rendered platform config: %w", err) + } + services, ok := configMap["services"].(map[interface{}]interface{}) + if !ok { + return tempFileName, errors.New("services section not found in platform config") + } + ersSection, err := buildEntityResolutionConfig(ersConfig, options.Hostname, scenarioOptions.LDAPPort) + if err != nil { + return tempFileName, fmt.Errorf("failed to build ERS config: %w", err) + } + services["entityresolution"] = ersSection + renderedBytes, err = yaml.Marshal(configMap) + if err != nil { + return tempFileName, fmt.Errorf("failed to marshal patched platform config: %w", err) + } + } + + err := os.WriteFile(tempFileName, renderedBytes, os.FileMode(0o755)) //nolint:mnd // mkdir dir if err != nil { return tempFileName, err } diff --git a/tests-bdd/features/multi-strategy-ers.feature b/tests-bdd/features/multi-strategy-ers.feature index 027ea707e8..85583d06af 100644 --- a/tests-bdd/features/multi-strategy-ers.feature +++ b/tests-bdd/features/multi-strategy-ers.feature @@ -7,7 +7,44 @@ Feature: Multi-strategy ERS entity resolution (Claims + LDAP) Background: Given an LDAP directory with test users - And a local platform with platform template "cukes/resources/platform.multi_strategy_ers.template" and keycloak template "cukes/resources/keycloak_base.template" + And an ERS configuration with mode "multi-strategy" and failure strategy "continue" + And an ERS provider "jwt_claims" of type "claims" + And an ERS provider "ldap_directory" of type "ldap" connected to the LDAP directory + And an ERS mapping strategy "claims_passthrough" using provider "jwt_claims" + """ + entity_type: subject + conditions: + jwt_claims: + - claim: userName + operator: exists + output_mapping: + - source_claim: userName + claim_name: username + """ + And an ERS mapping strategy "ldap_by_username" using provider "ldap_directory" + """ + entity_type: subject + conditions: + jwt_claims: + - claim: userName + operator: exists + ldap_search: + base_dn: "ou=users,dc=opentdf,dc=test" + filter: "(&(objectClass=inetOrgPerson)(uid={username}))" + scope: subtree + attributes: ["uid", "mail", "departmentNumber"] + input_mapping: + - jwt_claim: userName + parameter: username + output_mapping: + - source_attribute: departmentNumber + claim_name: department + - source_attribute: mail + claim_name: email + - source_attribute: uid + claim_name: username + """ + And a local platform with inline ERS configuration Scenario: LDAP-resolved engineering user gets PERMIT Given I submit a request to create a namespace with name "eng-permit.test" and reference id "ns_eng_permit" diff --git a/tests-bdd/platform_test.go b/tests-bdd/platform_test.go index bbb0a3af50..4475b66432 100644 --- a/tests-bdd/platform_test.go +++ b/tests-bdd/platform_test.go @@ -115,6 +115,7 @@ func runTests() int { cukes.RegisterKasRegistryStepDefinitions(ctx) cukes.RegisterEncryptionStepDefinitions(ctx) cukes.RegisterLDAPStepDefinitions(ctx, platformCukesContext) + cukes.RegisterERSStepDefinitions(ctx, platformCukesContext) platformCukesContext.InitializeScenario(ctx) }, Options: &opts, From 57a9d4abf8e3bb68920363d6bb32c5619dff2c6e Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Thu, 23 Jul 2026 15:39:14 -0400 Subject: [PATCH 7/8] chore: retrigger CI Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ken Ho From 68e24133971f446d9142510c4fd8206723f6eb70 Mon Sep 17 00:00:00 2001 From: Ken Ho Date: Thu, 23 Jul 2026 16:18:48 -0400 Subject: [PATCH 8/8] chore: retrigger CI