Skip to content

Add "dual_account_mode" to LDAP secrets engine allowing blue/green; also adds AD integration testing#226

Closed
andybaran wants to merge 8 commits intohashicorp:mainfrom
andybaran:main
Closed

Add "dual_account_mode" to LDAP secrets engine allowing blue/green; also adds AD integration testing#226
andybaran wants to merge 8 commits intohashicorp:mainfrom
andybaran:main

Conversation

@andybaran
Copy link
Copy Markdown

Overview

Add support for managing two LDAP service accounts per static role with blue/green rotation and configurable grace periods. This enables zero-downtime credential rotation for enterprise LDAP environments, particularly in financial services where even brief authentication failures during rotation cause cascading outages.

Problem

When Vault rotates a single LDAP service account password, there is a window where applications holding the old credential fail authentication. For organizations operating thousands of services across global data centers, this creates:

  • Customer-facing outages during credential rotation
  • Failed batch processing in core banking systems
  • Service disruptions that may impact availability SLAs and complicate compliance audits

Current workarounds (maintenance windows, application-level retry logic, external scripts) are operationally expensive and don't scale.

Solution

Dual-account rotation manages two LDAP accounts and alternates between them:

  1. Initial setup: Both accounts get fresh passwords, state starts as active with account A designated active
  2. On rotation trigger: The standby account's password is rotated, active designation flips, role enters grace_period
  3. During grace period: Both accounts' credentials are returned — applications transition at their own pace
  4. After grace period expires: Only the active account's credentials are returned

New API Fields

Static Role (POST /static-role/:name):

  • dual_account_mode (bool) — enable dual-account rotation
  • username_b / dn_b (string) — second LDAP account identity
  • grace_period (duration) — overlap period where both credentials are valid

Credential Read (GET /static-cred/:name during grace period):

  • username / password — active account credentials
  • standby_username / standby_password / standby_dn / standby_last_password — previous active account
  • active_account — which account is active (a or b)
  • rotation_stateactive or grace_period
  • grace_period_end — when the grace period expires

Design Decisions

  1. Native integration — Extends the existing staticAccount struct; reuses all existing infrastructure (LDAP client, WAL, priority queue, password policies, SealWrap, HA forwarding)
  2. dual_account_mode is immutable — Set at creation time only, prevents state corruption
  3. grace_period is required — No default; operators must explicitly configure it
  4. Initial setup path — Both accounts get passwords without entering grace period, so applications see a clean active state from the start
  5. Both usernames tracked in managedUsers — Prevents conflicts with library check-in/check-out

Files Changed

File Changes
path_static_roles.go Extended staticAccount struct (12 new fields), staticFields() (4 new schemas), role CRUD with dual-account validation and immutability
path_static_creds.go Returns active account as primary; standby credentials during grace period
rotation.go Added setDualAccountPassword() with initial setup + normal rotation paths; extended rotateCredential() for grace period expiry; extended populateQueue()
path_rotate.go Updated manual rotation queue scheduling for dual-account
backend.go Extended loadManagedUsers to track UsernameB
dual_account_test.go NEW: 11 comprehensive unit tests (including 5 library set conflict subtests)
ad_integration_test.go NEW: 5 AD integration tests against real Windows Server 2022 DC (including 2 library conflict subtests)
openldap_integration_test.go NEW: 3 OpenLDAP Docker integration subtests for library set conflicts

Total: +2049 / -15 lines across 8 files. Zero changes to client.go, path_config.go, path_dynamic_*.go, path_checkout*.go, or cmd/.

Testing

Unit Tests (11 new, all pass)

  • TestDualAccountRole_CreateValidation (7 sub-tests) — validation errors
  • TestDualAccountRole_HappyPath — full creation with DN
  • TestDualAccountRole_UsernameOnly — username-only mode
  • TestDualAccountRole_ManagedUserTracking — both usernames tracked
  • TestDualAccountRole_ImmutableFields — cannot change mode/username_b/dn_b after creation
  • TestDualAccountCreds_Read — active account returned as primary
  • TestDualAccountRotation_StateTransition — full rotation cycle (a→b→a)
  • TestDualAccountGracePeriodExpiry — grace period → active transition
  • TestDualAccountRole_SkipImportRotation — skip_import_rotation respected
  • TestDualAccountRole_UsernameB_AlreadyManaged — username conflict detection
  • TestDualAccountRole_LibrarySetConflict (5 sub-tests) — dual-account ↔ library set conflict prevention

AD Integration Tests (5 new, all pass)

Gated by env vars (AD_URL, AD_BIND_DN, AD_BIND_PW, AD_USER_DN, AD_DOMAIN):

  • TestAD_SingleAccountRotation — single-account rotation against AD with LDAP bind verification
  • TestAD_DualAccountRotation — dual-account initial setup, rotation, grace period, both accounts bindable
  • TestAD_DualAccountRotation_SecondRotation — full A→B→A cycle with automatic grace period expiry
  • TestAD_DualAccountUsernameOnlyMode — username-only (no DN) dual-account mode against AD
  • TestAD_LibrarySetConflict (2 sub-tests) — dual-account ↔ library set conflict prevention against real AD

OpenLDAP Docker Integration Tests (3 new subtests, all pass)

  • TestOpenLDAP_LibrarySetConflict — dual-account ↔ library set conflict prevention with real OpenLDAP Docker container

Pre-existing Test Failures (not related to this PR)

  • Test_UpdateDNPassword and Test_UpdateUserPassword — these fail on main branch; they require a live LDAP server.

Test Output (not run in CI)

Unit + OpenLDAP Docker Test Output

Click to expand unit + OpenLDAP Docker test output
=== RUN   TestDualAccountRole_CreateValidation
=== RUN   TestDualAccountRole_CreateValidation/missing_username_b_results_in_error
=== RUN   TestDualAccountRole_CreateValidation/missing_grace_period_results_in_error
=== RUN   TestDualAccountRole_CreateValidation/username_b_same_as_username_results_in_error
=== RUN   TestDualAccountRole_CreateValidation/grace_period_greater_than_rotation_period_results_in_error
=== RUN   TestDualAccountRole_CreateValidation/grace_period_equal_to_rotation_period_results_in_error
=== RUN   TestDualAccountRole_CreateValidation/grace_period_less_than_5_seconds_results_in_error
=== RUN   TestDualAccountRole_CreateValidation/empty_username_b_results_in_error
--- PASS: TestDualAccountRole_CreateValidation (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/missing_username_b_results_in_error (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/missing_grace_period_results_in_error (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/username_b_same_as_username_results_in_error (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/grace_period_greater_than_rotation_period_results_in_error (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/grace_period_equal_to_rotation_period_results_in_error (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/grace_period_less_than_5_seconds_results_in_error (0.00s)
    --- PASS: TestDualAccountRole_CreateValidation/empty_username_b_results_in_error (0.00s)
=== RUN   TestDualAccountRole_HappyPath
--- PASS: TestDualAccountRole_HappyPath (0.00s)
=== RUN   TestDualAccountRole_UsernameOnly
--- PASS: TestDualAccountRole_UsernameOnly (0.00s)
=== RUN   TestDualAccountRole_ManagedUserTracking
--- PASS: TestDualAccountRole_ManagedUserTracking (0.00s)
=== RUN   TestDualAccountRole_ImmutableFields
--- PASS: TestDualAccountRole_ImmutableFields (0.00s)
=== RUN   TestDualAccountCreds_Read
--- PASS: TestDualAccountCreds_Read (0.00s)
=== RUN   TestDualAccountRotation_StateTransition
--- PASS: TestDualAccountRotation_StateTransition (0.00s)
=== RUN   TestDualAccountGracePeriodExpiry
--- PASS: TestDualAccountGracePeriodExpiry (0.00s)
=== RUN   TestDualAccountRole_SkipImportRotation
--- PASS: TestDualAccountRole_SkipImportRotation (0.00s)
=== RUN   TestDualAccountRole_UsernameB_AlreadyManaged
--- PASS: TestDualAccountRole_UsernameB_AlreadyManaged (0.00s)
=== RUN   TestDualAccountRole_LibrarySetConflict
=== RUN   TestDualAccountRole_LibrarySetConflict/library_user_blocks_dual_account_username_b
=== RUN   TestDualAccountRole_LibrarySetConflict/library_user_blocks_dual_account_primary_username
=== RUN   TestDualAccountRole_LibrarySetConflict/dual_account_blocks_library_set_username_b
=== RUN   TestDualAccountRole_LibrarySetConflict/dual_account_blocks_library_set_primary_username
=== RUN   TestDualAccountRole_LibrarySetConflict/delete_dual_account_frees_both_usernames_for_library
--- PASS: TestDualAccountRole_LibrarySetConflict (0.00s)
    --- PASS: TestDualAccountRole_LibrarySetConflict/library_user_blocks_dual_account_username_b (0.00s)
    --- PASS: TestDualAccountRole_LibrarySetConflict/library_user_blocks_dual_account_primary_username (0.00s)
    --- PASS: TestDualAccountRole_LibrarySetConflict/dual_account_blocks_library_set_username_b (0.00s)
    --- PASS: TestDualAccountRole_LibrarySetConflict/dual_account_blocks_library_set_primary_username (0.00s)
    --- PASS: TestDualAccountRole_LibrarySetConflict/delete_dual_account_frees_both_usernames_for_library (0.00s)
=== RUN   TestOpenLDAP_LibrarySetConflict
=== RUN   TestOpenLDAP_LibrarySetConflict/library_blocks_dual_account_username_b
    openldap_integration_test.go:182: Correctly rejected: "svc-lib" is already managed by the secrets engine
=== RUN   TestOpenLDAP_LibrarySetConflict/dual_account_blocks_library_set
    openldap_integration_test.go:223: Correctly rejected: "svc-green" is already managed by the secrets engine
=== RUN   TestOpenLDAP_LibrarySetConflict/delete_dual_account_frees_usernames_for_library
    openldap_integration_test.go:270: Successfully reclaimed both usernames for library set after dual-account role deletion
--- PASS: TestOpenLDAP_LibrarySetConflict (11.48s)
    --- PASS: TestOpenLDAP_LibrarySetConflict/library_blocks_dual_account_username_b (0.01s)
    --- PASS: TestOpenLDAP_LibrarySetConflict/dual_account_blocks_library_set (0.01s)
    --- PASS: TestOpenLDAP_LibrarySetConflict/delete_dual_account_frees_usernames_for_library (0.04s)
PASS
ok  github.com/hashicorp/vault-plugin-secrets-openldap12.838s

AD Integration Test Output (Windows Server 2022 DC with LDAPS)

Click to expand AD integration test output
=== RUN   TestAD_SingleAccountRotation
    ad_integration_test.go:124: Single-account password set: 8e57... (length=64)
    ad_integration_test.go:152: Single-account rotated password: Qrol... (length=64)
    ad_integration_test.go:161: Skipping old-password-invalidation check (AD OldPasswordAllowedPeriod allows old passwords temporarily)
--- PASS: TestAD_SingleAccountRotation (2.26s)
=== RUN   TestAD_DualAccountRotation
    ad_integration_test.go:209: Initial state: active_account=a, state=active
    ad_integration_test.go:210: Account A password: AG4x... (len=64)
    ad_integration_test.go:222: Account B password: u9nh... (len=64)
    ad_integration_test.go:258: After rotation: active=B, state=grace_period
    ad_integration_test.go:259: New B password: vcio... (len=64)
    ad_integration_test.go:277: Skipping old B password invalidation check (AD OldPasswordAllowedPeriod)
    ad_integration_test.go:279: Dual-account rotation verified: both accounts bindable during grace period
--- PASS: TestAD_DualAccountRotation (3.03s)
=== RUN   TestAD_DualAccountRotation_SecondRotation
    ad_integration_test.go:320: Initial: A active, A pwd=KvVe..., B pwd=6UGF...
    ad_integration_test.go:338: After 1st rotation: B active, B pwd=aduz...
    ad_integration_test.go:345: Waiting for grace period to expire...
    ad_integration_test.go:379: After 2nd rotation: A active, A pwd=fHk8...
    ad_integration_test.go:389: Full A→B→A rotation cycle verified successfully
--- PASS: TestAD_DualAccountRotation_SecondRotation (19.47s)
=== RUN   TestAD_DualAccountUsernameOnlyMode
    ad_integration_test.go:429: Username-only dual-account mode works: active=a, state=active
--- PASS: TestAD_DualAccountUsernameOnlyMode (0.97s)
=== RUN   TestAD_LibrarySetConflict
=== RUN   TestAD_LibrarySetConflict/library_blocks_dual_account_username_b
    ad_integration_test.go:475: Correctly rejected: "svc-single" is already managed by the secrets engine
=== RUN   TestAD_LibrarySetConflict/dual_account_blocks_library_set
    ad_integration_test.go:514: Correctly rejected: "svc-rotate-b" is already managed by the secrets engine
--- PASS: TestAD_LibrarySetConflict (1.47s)
    --- PASS: TestAD_LibrarySetConflict/library_blocks_dual_account_username_b (0.52s)
    --- PASS: TestAD_LibrarySetConflict/dual_account_blocks_library_set (0.95s)
PASS
ok  github.com/hashicorp/vault-plugin-secrets-openldap28.217s

How to Test

# Start OpenLDAP with two service accounts
# Enable the openldap secrets engine
vault secrets enable -path=ldap openldap

# Configure LDAP connection
vault write ldap/config \
  binddn="cn=admin,dc=example,dc=org" \
  bindpass="adminpassword" \
  url="ldap://localhost"

# Create a dual-account static role
vault write ldap/static-role/my-app \
  username=svc-app-blue \
  dn="uid=svc-app-blue,ou=users,dc=example,dc=org" \
  username_b=svc-app-green \
  dn_b="uid=svc-app-green,ou=users,dc=example,dc=org" \
  rotation_period=24h \
  dual_account_mode=true \
  grace_period=5m

# Read credentials (active state)
vault read ldap/static-cred/my-app

# Trigger rotation
vault write -f ldap/rotate-role/my-app

# Read credentials (grace period — both accounts returned)
vault read ldap/static-cred/my-app

Compatibility

  • ✅ Works with Vault Community Edition and Enterprise
  • ✅ Backward compatible — existing single-account roles unaffected
  • ✅ Supports both username-based and DN-based LDAP resolution
  • ✅ Respects existing password policies
  • ✅ HA/DR forwarding via existing ForwardPerformanceStandby/Secondary

API Backward Compatibility & Client SDK Impact

All API changes are purely additive. Existing applications require ZERO code changes, even for single-account roles in the same environment. The new fields (dual_account_mode, active_account, rotation_state, standby_*, grace_period_end) only appear when dual_account_mode=true.

SDK-by-SDK Analysis

Each SDK was researched from source code to verify backward compatibility:

1. hvac (Python)

  • LDAP-specific API: Yes — client.secrets.ldap.generate_static_credentials(name) calls GET /v1/{mount}/static-cred/{name} and returns raw JSON
  • Response access: response['data']['password'], response['data']['username'], etc.
  • Impact on existing roles: None. Response is a Python dict — extra keys are silently available but ignored by code that doesn't reference them

2. Spring Vault / Spring Cloud Vault

  • LDAP-specific API: No — uses generic VaultTemplate.read(path) returning VaultResponse
  • Response access: response.getData().get("password")
  • Impact on existing roles: None. Map naturally accommodates extra keys

3. vault-java-driver (jopenlibs)

  • LDAP-specific API: No — uses generic vault.logical().read("ldap/static-cred/my-role") returning LogicalResponse
  • Impact on existing roles: None. Extra keys are simply extra map entries.

4. vaultgo (mittwald)

  • LDAP-specific API: No — only supports Transit, KV v1, PKI, and SSH. Has no LDAP support
  • Impact on existing roles: None. map[string]interface{} naturally accommodates extra fields
SDK Has LDAP-specific API? Response type Extra fields break existing code?
hvac (Python) Yes dict (JSON) No
Spring Vault No (generic read) Map No
vault-java-driver No (generic read) Map No
vaultgo (mittwald) No (uses official Go) map[string]interface{} No

Contributor Checklist

  • Add relevant docs to upstream Vault repository, or sufficient reasoning why docs won't be added yet
  • Add output for any tests not ran in CI to the PR description (eg, acceptance tests)
  • Backwards compatible

PCI Review Checklist

  • I have documented a clear reason for, and description of, the change I am making.

  • If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.

    Revert plan: This change is purely additive — all new fields are optional with zero-value defaults, no schema migrations are involved, and no existing fields are modified or removed. Reverting the PR reverts all changes completely. Existing single-account static roles created while this feature is active will continue to work after revert (dual-account fields are simply ignored when absent). Dual-account roles created while the feature is active would need to be deleted and recreated as single-account roles after revert.

  • If applicable, I've documented the impact of any changes to security controls.

    Security controls impact: Dual-account passwords are stored under the existing staticRolePath which already has SealWrapStorage: true, ensuring passwords are encrypted at rest via Vault's seal mechanism. Both username and username_b are tracked in the managedUsers map, preventing conflicts with the Service Account Library (check-in/check-out) system. All write operations inherit the existing ForwardPerformanceStandby: true and ForwardPerformanceSecondary: true settings on their path definitions, ensuring correct behavior in HA/DR configurations. Password generation uses the existing GeneratePassword method which respects configured password policies. WAL (Write-Ahead Log) entries are extended with NewPasswordB, UsernameB, and DNB fields for crash recovery during dual-account rotation, ensuring LDAP and Vault never diverge.

andybaran and others added 8 commits February 12, 2026 12:47
Add support for managing two LDAP service accounts per static role with
blue/green rotation and configurable grace periods, enabling zero-downtime
credential rotation for enterprise LDAP environments.

New fields on static roles:
- dual_account_mode (bool): enables dual-account rotation
- username_b / dn_b (string): second LDAP account identity
- grace_period (duration): overlap period where both credentials are valid

Rotation state machine:
- active: one account is active, standby is idle
- grace_period: both accounts' credentials returned after rotation

On rotation trigger, the standby account's password is rotated, active
designation flips, and the role enters grace_period. After the grace period
expires, the role returns to active state.

Initial setup sets both accounts' passwords without entering grace_period,
so applications see a clean active state from the start.

Reuses all existing infrastructure: LDAP client, WAL crash recovery,
priority queue rotation, password policies, SealWrap, and HA forwarding.

Includes 10 new unit tests covering validation, happy paths, managed user
tracking, immutability, credential reads, state transitions, grace period
expiry, skip_import_rotation, and username conflict detection.
Add integration tests that verify dual-account (blue/green) rotation
works against a real Active Directory domain controller:

- TestAD_SingleAccountRotation: baseline single-account rotation against AD
- TestAD_DualAccountRotation: full dual-account rotation with grace period
  verification, LDAP bind validation for both accounts
- TestAD_DualAccountRotation_SecondRotation: complete A→B→A rotation cycle
  with grace period expiry
- TestAD_DualAccountUsernameOnlyMode: username-based (no DN) dual-account mode

Tests are gated by AD_URL, AD_BIND_DN, AD_BIND_PW, AD_USER_DN, AD_DOMAIN
environment variables and will be skipped when not set.

Tested against Windows Server 2022 DC with AD CS (LDAPS) in AWS.
Verify that dual-account roles and Service Account Library (check-in/
check-out) correctly prevent username conflicts in both directions:

Unit tests (dual_account_test.go):
- TestDualAccountRole_LibrarySetConflict (5 subtests):
  - library_user_blocks_dual_account_username_b
  - library_user_blocks_dual_account_primary_username
  - dual_account_blocks_library_set_username_b
  - dual_account_blocks_library_set_primary_username
  - delete_dual_account_frees_both_usernames_for_library

OpenLDAP Docker integration tests (openldap_integration_test.go):
- TestOpenLDAP_LibrarySetConflict (3 subtests):
  - library_blocks_dual_account_username_b
  - dual_account_blocks_library_set
  - delete_dual_account_frees_usernames_for_library

AD integration tests (ad_integration_test.go):
- TestAD_LibrarySetConflict (2 subtests):
  - library_blocks_dual_account_username_b
  - dual_account_blocks_library_set

All tests verify the managedUsers tracking works correctly for both
username and username_b fields, ensuring no conflicts between static
roles, dual-account roles, and library sets.
…racking

Fix 6 issues identified in PR #2 code review:

1. path_rotate.go: Guard against nil resp dereference in manual rotation
   success path. Restructured conditional to only access resp.RotationTime
   when resp is non-nil.

2. rotation.go: Extend setCredentialsWAL struct with dual-account fields
   (NewPasswordB, UsernameB, DNB) so initial setup WAL records both
   account passwords for complete crash recovery.

3. rotation.go: Guard against zero-value GracePeriodEnd causing immediate
   incorrect transition. If GracePeriodEnd is zero, recompute it from
   LastVaultRotation + GracePeriod before checking expiry.

4. rotation.go: Fix LastRotationB asymmetry - now tracked symmetrically
   for both A and B account rotations.

5. rotation.go: Add WAL password reuse logic for dual-account normal
   rotation, matching single-account behavior. On retry after crash,
   reuses password from existing WAL instead of generating a new one.

6. openldap_integration_test.go: Return descriptive error when LDAP entry
   count validation fails instead of returning nil error.
Pre-upstream code quality improvements:

1. Add string constants for state machine values (rotationStateActive,
   rotationStateGracePeriod, activeAccountA, activeAccountB) to eliminate
   magic strings across rotation.go, path_static_roles.go,
   path_static_creds.go, and tests.

2. Fix LastRotationB bug: remove incorrect update in B→A rotation branch.
   LastRotationB should only update when account B's password is actually
   rotated (A→B branch and initial setup), not when switching away from B.

3. Block skip_import_rotation + dual_account_mode: dual-account mode
   requires initial rotation to set both passwords. API-level validation
   returns an error. Config-level SkipStaticRoleImportRotation is silently
   overridden for dual-account roles with a debug log.

4. Add WAL password reuse in initial setup path: if Vault crashes after
   setting account A's password but before B, recovery now reuses the
   WAL-stored passwords instead of generating new ones (matching the
   pattern in single-account and normal dual-account rotation).

5. Update help text: staticRoleHelpDescription now documents dual-account
   mode parameters, grace period behavior, immutability rules, and
   skip_import_rotation incompatibility.

6. Add grace_period sanity warning: logs a warning if grace_period > 80%
   of rotation_period, as this leaves very little time in active state.

All unit tests (11 dual-account + existing) pass.
OpenLDAP Docker integration tests (3 subtests) pass.
AD integration tests (5 tests against Windows Server 2022 DC) pass.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Align map literal field spacing (dn, dn_b) to match gofmt standards.
Fixes Go checks CI failure.
feat: Add dual-account (blue/green) rotation for static LDAP roles
@andybaran andybaran requested a review from a team as a code owner February 13, 2026 18:42
@andybaran
Copy link
Copy Markdown
Author

I have added the terraform code to create an Active Directory domain controller in AWS for Active Directory integration testing here

@andybaran andybaran closed this Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant