├── .go-version ├── scripts ├── docker │ ├── cleanup.sh │ ├── user.ldif │ └── setup.sh ├── gofmtcheck.sh └── build.sh ├── .github ├── workflows │ ├── go-checks.yaml │ ├── actionlint.yaml │ ├── backport.yaml │ ├── bulk-dep-upgrades.yaml │ ├── tests.yaml │ └── jira.yaml └── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── plugin ├── client │ ├── config.go │ ├── time_test.go │ ├── entry_test.go │ ├── fieldregistry_test.go │ ├── entry.go │ ├── tools │ │ └── simplecall.go │ ├── time.go │ ├── fieldregistry.go │ ├── client.go │ └── client_test.go ├── role.go ├── path_checkouts_test.go ├── passwords.go ├── role_test.go ├── ldapifc │ └── fakes.go ├── path_rotate_root_test.go ├── config.go ├── path_rotate_creds.go ├── util │ └── secrets_client.go ├── path_creds_test.go ├── rollback.go ├── backend.go ├── passwords_test.go ├── path_rotate_root.go ├── checkout_handler_test.go ├── path_config_test.go ├── config_test.go ├── checkout_handler.go ├── path_roles.go ├── path_creds.go ├── path_config.go ├── path_checkout_sets.go ├── path_checkouts.go └── backend_checkouts_test.go ├── .gitignore ├── cmd └── vault-plugin-secrets-ad │ └── main.go ├── Makefile ├── CHANGELOG.md ├── README.md ├── go.mod └── LICENSE /.go-version: -------------------------------------------------------------------------------- 1 | 1.25.1 2 | -------------------------------------------------------------------------------- /scripts/docker/cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | docker rm ad --force -------------------------------------------------------------------------------- /.github/workflows/go-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Go checks 2 | on: 3 | push: 4 | jobs: 5 | go-checks: 6 | # using `main` as the ref will keep your workflow up-to-date 7 | uses: hashicorp/vault-workflows-common/.github/workflows/go-checks.yaml@main -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions Workflows 2 | on: 3 | push: 4 | paths: 5 | - '.github/workflows/**' 6 | jobs: 7 | actionlint: 8 | # using `main` as the ref will keep your workflow up-to-date 9 | uses: hashicorp/vault-workflows-common/.github/workflows/actionlint.yaml@main -------------------------------------------------------------------------------- /scripts/docker/user.ldif: -------------------------------------------------------------------------------- 1 | dn: CN=Bob,CN=Users,DC=corp,DC=example,DC=net 2 | objectClass: top 3 | objectClass: person 4 | objectClass: organizationalPerson 5 | objectClass: user 6 | cn: Bob 7 | description: test account 8 | name: Bob 9 | sAMAccountName: Bob 10 | distinguishedName: CN=Bob,CN=Users,DC=corp,DC=example,DC=net 11 | userPrincipalName: Bob -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Each line is a file pattern followed by one or more owners. Being an owner 2 | # means those groups or individuals will be added as reviewers to PRs affecting 3 | # those areas of the code. 4 | # 5 | # More on CODEOWNERS files: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 6 | 7 | * @hashicorp/vault-ecosystem 8 | -------------------------------------------------------------------------------- /plugin/client/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 10 | ) 11 | 12 | type ADConf struct { 13 | *ldaputil.ConfigEntry 14 | LastBindPassword string `json:"last_bind_password"` 15 | LastBindPasswordRotation time.Time `json:"last_bind_password_rotation"` 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/backport.yaml: -------------------------------------------------------------------------------- 1 | name: Backport Assistant 2 | on: 3 | pull_request_target: 4 | types: 5 | - closed 6 | - labeled 7 | permissions: write-all 8 | jobs: 9 | backport: 10 | # using `main` as the ref will keep your workflow up-to-date 11 | uses: hashicorp/vault-workflows-common/.github/workflows/backport.yaml@main 12 | secrets: 13 | VAULT_ECO_GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /scripts/gofmtcheck.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | echo "==> Checking that code complies with gofmt requirements..." 7 | 8 | gofmt_files=$(gofmt -l `find . -name '*.go' | grep -v vendor`) 9 | if [[ -n ${gofmt_files} ]]; then 10 | echo 'gofmt needs running on the following files:' 11 | echo "${gofmt_files}" 12 | echo "You can use the command: \`make fmt\` to reformat code." 13 | exit 1 14 | fi -------------------------------------------------------------------------------- /plugin/client/time_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestParseTime(t *testing.T) { 11 | // This is a sample time returned from AD. 12 | pwdLastSet := "131680504285591921" 13 | lastSet, err := ParseTicks(pwdLastSet) 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | if lastSet.String() != "2018-04-12 23:47:08.5591921 +0000 UTC" { 18 | t.Fatalf("expected last set of \"2018-04-12 23:47:08.5591921 +0000 UTC\" but received %q", lastSet.String()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/bulk-dep-upgrades.yaml: -------------------------------------------------------------------------------- 1 | name: Upgrade dependencies 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # Runs 12:00AM on the first of every month 6 | - cron: '0 0 1 * *' 7 | jobs: 8 | upgrade: 9 | # using `main` as the ref will keep your workflow up-to-date 10 | uses: hashicorp/vault-workflows-common/.github/workflows/bulk-dependency-updates.yaml@main 11 | secrets: 12 | VAULT_ECO_GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} 13 | with: 14 | reviewer-team: hashicorp/vault-ecosystem-applications 15 | repository: ${{ github.repository }} 16 | run-id: ${{ github.run_id }} 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | push: 4 | jobs: 5 | run-tests: 6 | # using `main` as the ref will keep your workflow up-to-date 7 | uses: hashicorp/vault-workflows-common/.github/workflows/tests.yaml@main 8 | 9 | run-tests-race: 10 | name: "Run Tests Race" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 14 | - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 15 | with: 16 | go-version-file: .go-version 17 | cache: true 18 | - name: Run Tests (Race) 19 | run: make testrace 20 | -------------------------------------------------------------------------------- /plugin/client/entry_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/go-ldap/ldap/v3" 11 | ) 12 | 13 | // Since `$ make test` is run with the -race flag, this will detect a race and fail if it's racy. 14 | func TestIfEntryCreationIsRacy(t *testing.T) { 15 | for i := 0; i < 10000; i++ { 16 | go func() { 17 | ldapEntry := &ldap.Entry{ 18 | Attributes: []*ldap.EntryAttribute{ 19 | {Name: "hello", Values: []string{"world"}}, 20 | }, 21 | } 22 | NewEntry(ldapEntry) 23 | }() 24 | } 25 | // Chill out for a second to let everything run. 26 | time.Sleep(time.Second) 27 | } 28 | -------------------------------------------------------------------------------- /scripts/docker/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | 8 | ${DIR?}/cleanup.sh 9 | 10 | set -e 11 | 12 | docker run \ 13 | --name=ad \ 14 | --hostname=ad \ 15 | --privileged \ 16 | -p 389:389 \ 17 | -p 636:636 \ 18 | -e SAMBA_DC_REALM="corp.example.net" \ 19 | -e SAMBA_DC_DOMAIN="EXAMPLE" \ 20 | -e SAMBA_DC_ADMIN_PASSWD="SuperSecretPassw0rd" \ 21 | -e SAMBA_DC_DNS_BACKEND="SAMBA_INTERNAL" \ 22 | --detach "laslabs/alpine-samba-dc" samba 23 | 24 | sleep 30 25 | 26 | LDAPTLS_REQCERT=never ldapadd -h 127.0.0.1 -Z -p 389 -w "SuperSecretPassw0rd" -D "CN=Administrator,CN=Users,DC=corp,DC=example,DC=net" -f user.ldif 27 | -------------------------------------------------------------------------------- /.github/workflows/jira.yaml: -------------------------------------------------------------------------------- 1 | name: Jira Sync 2 | on: 3 | issues: 4 | types: [opened, closed, deleted, reopened] 5 | pull_request_target: 6 | types: [opened, closed, reopened] 7 | issue_comment: # Also triggers when commenting on a PR from the conversation view 8 | types: [created] 9 | jobs: 10 | sync: 11 | uses: hashicorp/vault-workflows-common/.github/workflows/jira.yaml@main 12 | # assuming you use Vault to get secrets 13 | # if you use GitHub secrets, use secrets.XYZ instead of steps.secrets.outputs.XYZ 14 | secrets: 15 | JIRA_SYNC_BASE_URL: ${{ secrets.JIRA_SYNC_BASE_URL }} 16 | JIRA_SYNC_USER_EMAIL: ${{ secrets.JIRA_SYNC_USER_EMAIL }} 17 | JIRA_SYNC_API_TOKEN: ${{ secrets.JIRA_SYNC_API_TOKEN }} 18 | with: 19 | teams-array: '["ecosystem", "applications"]' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | .cover 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | 27 | # Other dirs 28 | /bin/ 29 | /pkg/ 30 | 31 | # Vault-specific 32 | example.hcl 33 | example.vault.d 34 | 35 | # Vagrant 36 | .vagrant/ 37 | Vagrantfile 38 | 39 | # Configs 40 | *.hcl 41 | 42 | .DS_Store 43 | .idea 44 | .vscode 45 | 46 | dist/* 47 | 48 | tags 49 | 50 | # Editor backups 51 | *~ 52 | *.sw[a-z] 53 | 54 | # IntelliJ IDEA project files 55 | .idea 56 | *.ipr 57 | *.iml 58 | 59 | # binary 60 | bin 61 | -------------------------------------------------------------------------------- /plugin/role.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "time" 8 | ) 9 | 10 | type backendRole struct { 11 | ServiceAccountName string `json:"service_account_name"` 12 | TTL int `json:"ttl"` 13 | LastVaultRotation time.Time `json:"last_vault_rotation"` 14 | PasswordLastSet time.Time `json:"password_last_set"` 15 | } 16 | 17 | func (r *backendRole) Map() map[string]interface{} { 18 | m := map[string]interface{}{ 19 | "service_account_name": r.ServiceAccountName, 20 | "ttl": r.TTL, 21 | } 22 | 23 | var unset time.Time 24 | if r.LastVaultRotation != unset { 25 | m["last_vault_rotation"] = r.LastVaultRotation 26 | } 27 | if r.PasswordLastSet != unset { 28 | m["password_last_set"] = r.PasswordLastSet 29 | } 30 | return m 31 | } 32 | -------------------------------------------------------------------------------- /cmd/vault-plugin-secrets-ad/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | 9 | hclog "github.com/hashicorp/go-hclog" 10 | "github.com/hashicorp/vault/api" 11 | "github.com/hashicorp/vault/sdk/plugin" 12 | 13 | ad "github.com/hashicorp/vault-plugin-secrets-ad/plugin" 14 | ) 15 | 16 | func main() { 17 | apiClientMeta := &api.PluginAPIClientMeta{} 18 | flags := apiClientMeta.FlagSet() 19 | flags.Parse(os.Args[1:]) 20 | 21 | tlsConfig := apiClientMeta.GetTLSConfig() 22 | tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) 23 | 24 | err := plugin.ServeMultiplex(&plugin.ServeOpts{ 25 | BackendFactoryFunc: ad.Factory, 26 | // set the TLSProviderFunc so that the plugin maintains backwards 27 | // compatibility with Vault versions that don’t support plugin AutoMTLS 28 | TLSProviderFunc: tlsProviderFunc, 29 | }) 30 | if err != nil { 31 | logger := hclog.New(&hclog.LoggerOptions{}) 32 | 33 | logger.Error("plugin shutting down", "error", err) 34 | os.Exit(1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /plugin/client/fieldregistry_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestFieldRegistryListsFields(t *testing.T) { 11 | fields := FieldRegistry.List() 12 | if len(fields) != 40 { 13 | t.FailNow() 14 | } 15 | } 16 | 17 | func TestFieldRegistryEqualityComparisonsWork(t *testing.T) { 18 | fields := FieldRegistry.List() 19 | 20 | foundGivenName := false 21 | foundSurname := false 22 | for _, field := range fields { 23 | if field == FieldRegistry.GivenName { 24 | foundGivenName = true 25 | } 26 | if field == FieldRegistry.Surname { 27 | foundSurname = true 28 | } 29 | } 30 | 31 | if !foundGivenName || !foundSurname { 32 | t.Fatal("the field registry's equality comparisons are not working") 33 | } 34 | } 35 | 36 | func TestFieldRegistryParsesFieldsByString(t *testing.T) { 37 | field := FieldRegistry.Parse("sn") 38 | if field == nil { 39 | t.Fatal("field not found") 40 | } 41 | if field != FieldRegistry.Surname { 42 | t.Fatal("the field registry is unable to parse registry fields from their string representations") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /plugin/client/entry.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/go-ldap/ldap/v3" 10 | ) 11 | 12 | // Entry is an Active Directory-specific construct 13 | // to make knowing and grabbing fields more convenient, 14 | // while retaining all original information. 15 | func NewEntry(ldapEntry *ldap.Entry) *Entry { 16 | fieldMap := make(map[string][]string) 17 | for _, attribute := range ldapEntry.Attributes { 18 | field := FieldRegistry.Parse(attribute.Name) 19 | if field == nil { 20 | // This field simply isn't in the registry, no big deal. 21 | continue 22 | } 23 | fieldMap[field.String()] = attribute.Values 24 | } 25 | return &Entry{fieldMap: fieldMap, Entry: ldapEntry} 26 | } 27 | 28 | type Entry struct { 29 | *ldap.Entry 30 | fieldMap map[string][]string 31 | } 32 | 33 | func (e *Entry) Get(field *Field) ([]string, bool) { 34 | values, found := e.fieldMap[field.String()] 35 | return values, found 36 | } 37 | 38 | func (e *Entry) GetJoined(field *Field) (string, bool) { 39 | values, found := e.Get(field) 40 | if !found { 41 | return "", false 42 | } 43 | return strings.Join(values, ","), true 44 | } 45 | -------------------------------------------------------------------------------- /plugin/path_checkouts_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/hashicorp/vault/sdk/logical" 10 | ) 11 | 12 | func TestCheckInAuthorized(t *testing.T) { 13 | can := checkinAuthorized(&logical.Request{EntityID: "foo"}, &CheckOut{BorrowerEntityID: "foo"}) 14 | if !can { 15 | t.Fatal("the entity that checked out the secret should be able to check it in") 16 | } 17 | can = checkinAuthorized(&logical.Request{ClientToken: "foo"}, &CheckOut{BorrowerClientToken: "foo"}) 18 | if !can { 19 | t.Fatal("the client token that checked out the secret should be able to check it in") 20 | } 21 | can = checkinAuthorized(&logical.Request{EntityID: "fizz"}, &CheckOut{BorrowerEntityID: "buzz"}) 22 | if can { 23 | t.Fatal("other entities shouldn't be able to perform check-ins") 24 | } 25 | can = checkinAuthorized(&logical.Request{ClientToken: "fizz"}, &CheckOut{BorrowerClientToken: "buzz"}) 26 | if can { 27 | t.Fatal("other tokens shouldn't be able to perform check-ins") 28 | } 29 | can = checkinAuthorized(&logical.Request{}, &CheckOut{}) 30 | if can { 31 | t.Fatal("when insufficient auth info is provided, check-in should not be allowed") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | A high level description of the contribution, including: 3 | Who the change affects or is for (stakeholders)? 4 | What is the change? 5 | Why is the change needed? 6 | How does this change affect the user experience (if at all)? 7 | 8 | # Design of Change 9 | How was this change implemented? 10 | 11 | # Related Issues/Pull Requests 12 | [ ] [Issue #1234](https://github.com/hashicorp/vault/issues/1234) 13 | [ ] [PR #1234](https://github.com/hashicorp/vault/pr/1234) 14 | 15 | # Contributor Checklist 16 | [ ] Add relevant docs to upstream Vault repository, or sufficient reasoning why docs won’t be added yet 17 | [My Docs PR Link](link) 18 | [Example](https://github.com/hashicorp/vault/commit/2715f5cec982aabc7b7a6ae878c547f6f475bba6) 19 | [ ] Add output for any tests not ran in CI to the PR description (eg, acceptance tests) 20 | [ ] Backwards compatible 21 | 22 | ## PCI review checklist 23 | 24 | 25 | 26 | - [ ] I have documented a clear reason for, and description of, the change I am making. 27 | 28 | - [ ] If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request. 29 | 30 | - [ ] If applicable, I've documented the impact of any changes to security controls. 31 | 32 | Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc. 33 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | TOOL=vault-plugin-secrets-ad 7 | 8 | # This script builds the application from source for multiple platforms. 9 | set -e 10 | 11 | GO_CMD=${GO_CMD:-go} 12 | 13 | # Get the parent directory of where this script is. 14 | SOURCE="${BASH_SOURCE[0]}" 15 | while [ -h "$SOURCE" ] ; do SOURCE="$(readlink "$SOURCE")"; done 16 | DIR="$( cd -P "$( dirname "$SOURCE" )/.." && pwd )" 17 | 18 | # Change into that directory 19 | cd "$DIR" 20 | 21 | # Set build tags 22 | BUILD_TAGS="${BUILD_TAGS}:-${TOOL}" 23 | 24 | # Get the git commit 25 | GIT_COMMIT="$(git rev-parse HEAD)" 26 | GIT_DIRTY="$(test -n "`git status --porcelain`" && echo "+CHANGES" || true)" 27 | 28 | GOPATH=${GOPATH:-$(go env GOPATH)} 29 | case $(uname) in 30 | CYGWIN*) 31 | GOPATH="$(cygpath $GOPATH)" 32 | ;; 33 | esac 34 | 35 | # Delete the old dir 36 | echo "==> Removing old directory..." 37 | rm -f bin/* 38 | rm -rf pkg/* 39 | mkdir -p bin/ 40 | 41 | # Build! 42 | echo "==> Building..." 43 | ${GO_CMD} build \ 44 | -gcflags "${GCFLAGS}" \ 45 | -ldflags "-X github.com/hashicorp/${TOOL}/version.GitCommit='${GIT_COMMIT}${GIT_DIRTY}'" \ 46 | -o "bin/${TOOL}" \ 47 | -tags "${BUILD_TAGS}" \ 48 | "cmd/${TOOL}/main.go" 49 | 50 | # Move all the compiled things to the $GOPATH/bin 51 | OLDIFS=$IFS 52 | IFS=: MAIN_GOPATH=($GOPATH) 53 | IFS=$OLDIFS 54 | 55 | rm -f ${MAIN_GOPATH}/bin/${TOOL} 56 | cp bin/${TOOL} ${MAIN_GOPATH}/bin/ 57 | 58 | # Done! 59 | echo 60 | echo "==> Results:" 61 | ls -hl bin/ 62 | -------------------------------------------------------------------------------- /plugin/client/tools/simplecall.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/hashicorp/go-hclog" 11 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 12 | 13 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 14 | ) 15 | 16 | var ( 17 | // ex. "ldap://138.91.247.105" 18 | rawURL = os.Getenv("TEST_LDAP_URL") 19 | dn = os.Getenv("TEST_DN") 20 | 21 | // these can be left blank if the operation performed doesn't require them 22 | username = os.Getenv("TEST_LDAP_USERNAME") 23 | password = os.Getenv("TEST_LDAP_PASSWORD") 24 | ) 25 | 26 | // main executes one call using a simple client pointed at the given instance. 27 | func main() { 28 | config := newInsecureConfig() 29 | c := client.NewClient(hclog.Default()) 30 | 31 | filters := map[*client.Field][]string{ 32 | client.FieldRegistry.GivenName: {"Sara", "Sarah"}, 33 | } 34 | 35 | entries, err := c.Search(config, config.UserDN, filters) 36 | if err != nil { 37 | fmt.Println(err.Error()) 38 | return 39 | } 40 | 41 | fmt.Printf("found %d entries:\n", len(entries)) 42 | for _, entry := range entries { 43 | fmt.Printf("%+v\n", entry) 44 | } 45 | } 46 | 47 | func newInsecureConfig() *client.ADConf { 48 | return &client.ADConf{ 49 | ConfigEntry: &ldaputil.ConfigEntry{ 50 | UserDN: dn, 51 | Certificate: "", 52 | InsecureTLS: true, 53 | BindPassword: password, 54 | TLSMinVersion: "tls12", 55 | TLSMaxVersion: "tls12", 56 | Url: rawURL, 57 | BindDN: username, 58 | }, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TOOL?=vault-plugin-secrets-ad 2 | TEST?=$$(go list ./... | grep -v /vendor/ | grep -v teamcity) 3 | EXTERNAL_TOOLS= 4 | BUILD_TAGS?=${TOOL} 5 | GOFMT_FILES?=$$(find . -name '*.go' | grep -v vendor) 6 | 7 | # bin generates the releaseable binaries for this plugin 8 | bin: fmtcheck generate 9 | CGO_ENABLED=0 BUILD_TAGS='$(BUILD_TAGS)' sh -c "'$(CURDIR)/scripts/build.sh'" 10 | 11 | default: dev 12 | 13 | # dev creates binaries for testing Vault locally. These are put 14 | # into ./bin/ as well as $GOPATH/bin. 15 | dev: fmtcheck generate 16 | CGO_ENABLED=0 BUILD_TAGS='$(BUILD_TAGS)' VAULT_DEV_BUILD=1 sh -c "'$(CURDIR)/scripts/build.sh'" 17 | 18 | # testshort runs the quick unit tests and vets the code 19 | test: fmtcheck generate 20 | CGO_ENABLED=0 VAULT_TOKEN= VAULT_ACC= go test -v -short -tags='$(BUILD_TAGS)' $(TEST) $(TESTARGS) -count=1 -timeout=20m -parallel=4 21 | 22 | # test runs the unit tests and vets the code 23 | testrace: fmtcheck generate 24 | CGO_ENABLED=1 VAULT_TOKEN= VAULT_ACC= go test -race -v -tags='$(BUILD_TAGS)' $(TEST) $(TESTARGS) -count=1 -timeout=20m -parallel=4 25 | 26 | testcompile: fmtcheck generate 27 | @for pkg in $(TEST) ; do \ 28 | go test -v -c -tags='$(BUILD_TAGS)' $$pkg -parallel=4 ; \ 29 | done 30 | 31 | # generate runs `go generate` to build the dynamically generated 32 | # source files. 33 | generate: 34 | go generate ./... 35 | 36 | # bootstrap the build by downloading additional tools 37 | bootstrap: 38 | @for tool in $(EXTERNAL_TOOLS) ; do \ 39 | echo "Installing/Updating $$tool" ; \ 40 | go get -u $$tool; \ 41 | done 42 | 43 | fmtcheck: 44 | sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" 45 | 46 | fmt: 47 | gofmt -w $(GOFMT_FILES) 48 | 49 | proto: 50 | protoc *.proto --go_out=plugins=grpc:. 51 | 52 | .PHONY: bin default generate test vet bootstrap fmt fmtcheck 53 | -------------------------------------------------------------------------------- /plugin/passwords.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "strings" 9 | 10 | "github.com/hashicorp/go-secure-stdlib/base62" 11 | ) 12 | 13 | var ( 14 | // Per https://en.wikipedia.org/wiki/Password_strength#Guidelines_for_strong_passwords 15 | minimumLengthOfComplexString = 8 16 | 17 | passwordComplexityPrefix = "?@09AZ" 18 | pwdFieldTmpl = "{{PASSWORD}}" 19 | ) 20 | 21 | type passwordGenerator interface { 22 | GeneratePasswordFromPolicy(ctx context.Context, policyName string) (password string, err error) 23 | } 24 | 25 | // GeneratePassword from the password configuration. This will either generate based on a password policy 26 | // or from the provided formatter. The formatter/length options are deprecated. 27 | func GeneratePassword(ctx context.Context, passConf passwordConf, generator passwordGenerator) (password string, err error) { 28 | err = passConf.validate() 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | if passConf.PasswordPolicy != "" { 34 | return generator.GeneratePasswordFromPolicy(ctx, passConf.PasswordPolicy) 35 | } 36 | return generateDeprecatedPassword(passConf.Formatter, passConf.Length) 37 | } 38 | 39 | func generateDeprecatedPassword(formatter string, totalLength int) (string, error) { 40 | // Has formatter 41 | if formatter != "" { 42 | passLen := lengthOfPassword(formatter, totalLength) 43 | pwd, err := base62.Random(passLen) 44 | if err != nil { 45 | return "", err 46 | } 47 | return strings.Replace(formatter, pwdFieldTmpl, pwd, 1), nil 48 | } 49 | 50 | // Doesn't have formatter 51 | pwd, err := base62.Random(totalLength - len(passwordComplexityPrefix)) 52 | if err != nil { 53 | return "", err 54 | } 55 | return passwordComplexityPrefix + pwd, nil 56 | } 57 | 58 | func lengthOfPassword(formatter string, totalLength int) int { 59 | lengthOfText := len(formatter) - len(pwdFieldTmpl) 60 | return totalLength - lengthOfText 61 | } 62 | -------------------------------------------------------------------------------- /plugin/client/time.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | const ( 12 | nanoSecondsPerSecond = 1000000000 13 | nanosInTick = 100 14 | ticksPerSecond = nanoSecondsPerSecond / nanosInTick 15 | ) 16 | 17 | // ParseTicks parses dates represented as Active Directory LargeInts into times. 18 | // Not all time fields are represented this way, 19 | // so be sure to test that your particular time returns expected results. 20 | // Some time fields represented as LargeInts include accountExpires, lastLogon, lastLogonTimestamp, and pwdLastSet. 21 | // More: https://social.technet.microsoft.com/wiki/contents/articles/31135.active-directory-large-integer-attributes.aspx 22 | func ParseTicks(ticks string) (time.Time, error) { 23 | i, err := strconv.ParseInt(ticks, 10, 64) 24 | if err != nil { 25 | return time.Time{}, err 26 | } 27 | return TicksToTime(i), nil 28 | } 29 | 30 | // TicksToTime converts an ActiveDirectory time in ticks to a time. 31 | // This algorithm is summarized as: 32 | // 33 | // Many dates are saved in Active Directory as Large Integer values. 34 | // These attributes represent dates as the number of 100-nanosecond intervals since 12:00 AM January 1, 1601. 35 | // 100-nanosecond intervals, equal to 0.0000001 seconds, are also called ticks. 36 | // Dates in Active Directory are always saved in Coordinated Universal Time, or UTC. 37 | // More: https://social.technet.microsoft.com/wiki/contents/articles/31135.active-directory-large-integer-attributes.aspx 38 | // 39 | // If we directly follow the above algorithm we encounter time.Duration limits of 290 years and int overflow issues. 40 | // Thus below, we carefully sidestep those. 41 | func TicksToTime(ticks int64) time.Time { 42 | origin := time.Date(1601, time.January, 1, 0, 0, 0, 0, time.UTC).Unix() 43 | secondsSinceOrigin := ticks / ticksPerSecond 44 | remainingNanoseconds := ticks % ticksPerSecond * 100 45 | return time.Unix(origin+secondsSinceOrigin, remainingNanoseconds).UTC() 46 | } 47 | -------------------------------------------------------------------------------- /plugin/role_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/hashicorp/vault/sdk/framework" 11 | ) 12 | 13 | var ( 14 | defaultLeaseTTLVal = time.Second * 100 15 | defaultTTLInt = int(defaultLeaseTTLVal.Seconds()) 16 | 17 | maxLeaseTTLVal = time.Second * 200 18 | maxTTLInt = int(maxLeaseTTLVal.Seconds()) 19 | 20 | schema = testBackend.pathRoles().Fields 21 | ) 22 | 23 | func TestOnlyDefaultTTLs(t *testing.T) { 24 | passwordConf := passwordConf{ 25 | TTL: defaultTTLInt, 26 | MaxTTL: maxTTLInt, 27 | Length: defaultPasswordLength, 28 | } 29 | 30 | fieldData := &framework.FieldData{ 31 | Raw: map[string]interface{}{ 32 | "service_account_name": "kibana@example.com", 33 | }, 34 | Schema: schema, 35 | } 36 | 37 | ttl, err := getValidatedTTL(passwordConf, fieldData) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | if ttl != defaultTTLInt { 43 | t.Fatal("ttl is not defaulting properly") 44 | } 45 | } 46 | 47 | func TestCustomOperatorTTLButDefaultRoleTTL(t *testing.T) { 48 | passwordConf := passwordConf{ 49 | TTL: 10, 50 | MaxTTL: maxTTLInt, 51 | Length: defaultPasswordLength, 52 | } 53 | 54 | fieldData := &framework.FieldData{ 55 | Raw: map[string]interface{}{ 56 | "service_account_name": "kibana@example.com", 57 | }, 58 | Schema: schema, 59 | } 60 | 61 | ttl, err := getValidatedTTL(passwordConf, fieldData) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | if ttl != 10 { 67 | t.Fatal("ttl is not defaulting properly") 68 | } 69 | } 70 | 71 | func TestTTLTooHigh(t *testing.T) { 72 | passwordConf := passwordConf{ 73 | TTL: 10, 74 | MaxTTL: 10, 75 | Length: defaultPasswordLength, 76 | } 77 | 78 | fieldData := &framework.FieldData{ 79 | Raw: map[string]interface{}{ 80 | "service_account_name": "kibana@example.com", 81 | "ttl": 100, 82 | }, 83 | Schema: schema, 84 | } 85 | 86 | _, err := getValidatedTTL(passwordConf, fieldData) 87 | if err == nil { 88 | t.Fatal("should error when ttl is too high") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /plugin/ldapifc/fakes.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package ldapifc 5 | 6 | import ( 7 | "crypto/tls" 8 | "fmt" 9 | "reflect" 10 | "time" 11 | 12 | "github.com/go-ldap/ldap/v3" 13 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 14 | ) 15 | 16 | var _ ldaputil.LDAP = &FakeLDAPClient{} 17 | 18 | // FakeLDAPClient can be used to inspect the LDAP requests that have been constructed, 19 | // and to inject responses. 20 | type FakeLDAPClient struct { 21 | ConnToReturn ldaputil.Connection 22 | } 23 | 24 | func (f *FakeLDAPClient) DialURL(addr string, opts ...ldap.DialOpt) (ldaputil.Connection, error) { 25 | return f.ConnToReturn, nil 26 | } 27 | 28 | var _ ldaputil.Connection = &FakeLDAPConnection{} 29 | 30 | type FakeLDAPConnection struct { 31 | ModifyRequestToExpect *ldap.ModifyRequest 32 | SearchRequestToExpect *ldap.SearchRequest 33 | SearchResultToReturn *ldap.SearchResult 34 | } 35 | 36 | func (f *FakeLDAPConnection) Add(addRequest *ldap.AddRequest) error { 37 | return nil 38 | } 39 | 40 | func (f *FakeLDAPConnection) Del(delRequest *ldap.DelRequest) error { 41 | return nil 42 | } 43 | 44 | func (f *FakeLDAPConnection) Bind(username, password string) error { 45 | return nil 46 | } 47 | 48 | func (f *FakeLDAPConnection) Close() error { 49 | return nil 50 | } 51 | 52 | func (f *FakeLDAPConnection) Modify(modifyRequest *ldap.ModifyRequest) error { 53 | if !reflect.DeepEqual(f.ModifyRequestToExpect, modifyRequest) { 54 | return fmt.Errorf("expected modifyRequest of %#v, but received %#v", f.ModifyRequestToExpect, modifyRequest) 55 | } 56 | return nil 57 | } 58 | 59 | func (f *FakeLDAPConnection) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { 60 | if f.SearchRequestToExpect.BaseDN != searchRequest.BaseDN { 61 | return nil, fmt.Errorf("expected searchRequest of %v, but received %v", f.SearchRequestToExpect, searchRequest) 62 | } 63 | if f.SearchRequestToExpect.Scope != searchRequest.Scope { 64 | return nil, fmt.Errorf("expected searchRequest of %v, but received %v", f.SearchRequestToExpect, searchRequest) 65 | } 66 | if f.SearchRequestToExpect.Filter != searchRequest.Filter { 67 | return nil, fmt.Errorf("expected searchRequest of %v, but received %v", f.SearchRequestToExpect, searchRequest) 68 | } 69 | return f.SearchResultToReturn, nil 70 | } 71 | 72 | func (f *FakeLDAPConnection) StartTLS(config *tls.Config) error { 73 | return nil 74 | } 75 | 76 | func (f *FakeLDAPConnection) SetTimeout(timeout time.Duration) {} 77 | 78 | func (f *FakeLDAPConnection) UnauthenticatedBind(username string) error { 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /plugin/path_rotate_root_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/go-errors/errors" 11 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 12 | 13 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 14 | ) 15 | 16 | // Tests to check root credential rotation are found in ./backend_test.go 17 | 18 | func TestRollBackPassword(t *testing.T) { 19 | if testing.Short() { 20 | t.Skip() 21 | } 22 | 23 | b := testBackend 24 | doneChan := make(chan struct{}) 25 | ctx := &testContext{doneChan} 26 | testConf := &configuration{ 27 | ADConf: &client.ADConf{ 28 | ConfigEntry: &ldaputil.ConfigEntry{ 29 | BindDN: "cats", 30 | }, 31 | }, 32 | } 33 | 34 | // Test succeeds immediately with successful response. 35 | if err := b.rollBackRootPassword(ctx, testConf, "testing"); err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | b.client = &badFake{} 40 | 41 | // Test can be that retrying can be interrupted after 10 seconds using ctx. 42 | stopped := make(chan struct{}) 43 | go func() { 44 | defer close(stopped) 45 | b.rollBackRootPassword(ctx, testConf, "testing") 46 | }() 47 | 48 | // Wait 30 seconds and then close the doneChan, which should cause rollback to stop. 49 | timer := time.NewTimer(time.Second * 30) 50 | select { 51 | case <-timer.C: 52 | close(doneChan) 53 | } 54 | 55 | timer.Reset(time.Second) 56 | select { 57 | case <-timer.C: 58 | t.Fatal("should have stopped by now") 59 | case <-stopped: 60 | // pass 61 | } 62 | } 63 | 64 | type testContext struct { 65 | doneChan chan struct{} 66 | } 67 | 68 | func (c *testContext) Deadline() (deadline time.Time, ok bool) { 69 | return time.Time{}, false 70 | } 71 | 72 | func (c *testContext) Done() <-chan struct{} { 73 | return c.doneChan 74 | } 75 | 76 | func (c *testContext) Err() error { 77 | return nil 78 | } 79 | 80 | func (c *testContext) Value(key interface{}) interface{} { 81 | return nil 82 | } 83 | 84 | type badFake struct{} 85 | 86 | func (f *badFake) Get(conf *client.ADConf, serviceAccountName string) (*client.Entry, error) { 87 | return nil, errors.New("nope") 88 | } 89 | 90 | func (f *badFake) GetPasswordLastSet(conf *client.ADConf, serviceAccountName string) (time.Time, error) { 91 | return time.Time{}, errors.New("nope") 92 | } 93 | 94 | func (f *badFake) UpdatePassword(conf *client.ADConf, serviceAccountName string, newPassword string) error { 95 | return errors.New("nope") 96 | } 97 | 98 | func (f *badFake) UpdateRootPassword(conf *client.ADConf, bindDN string, newPassword string) error { 99 | return errors.New("nope") 100 | } 101 | -------------------------------------------------------------------------------- /plugin/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 11 | ) 12 | 13 | type configuration struct { 14 | PasswordConf passwordConf 15 | ADConf *client.ADConf 16 | LastRotationTolerance int 17 | } 18 | 19 | type passwordConf struct { 20 | TTL int `json:"ttl"` 21 | MaxTTL int `json:"max_ttl"` 22 | 23 | // Mutually exclusive with Length and Formatter 24 | PasswordPolicy string `json:"password_policy"` 25 | 26 | // Length of the password to generate. Mutually exclusive with PasswordPolicy. 27 | // Deprecated 28 | Length int `json:"length"` 29 | 30 | // Formatter describes how to format a password. This allows for prefixes and suffixes on the password. 31 | // Mutually exclusive with PasswordPolicy. 32 | // Deprecated 33 | Formatter string `json:"formatter"` 34 | } 35 | 36 | func (c passwordConf) Map() map[string]interface{} { 37 | return map[string]interface{}{ 38 | "ttl": c.TTL, 39 | "max_ttl": c.MaxTTL, 40 | "length": c.Length, 41 | "formatter": c.Formatter, 42 | "password_policy": c.PasswordPolicy, 43 | } 44 | } 45 | 46 | // validate returns an error if the configuration is invalid/unable to process for whatever reason. 47 | func (c passwordConf) validate() error { 48 | if c.PasswordPolicy != "" && 49 | (c.Length != 0 || c.Formatter != "") { 50 | return fmt.Errorf("cannot set password_policy and either length or formatter") 51 | } 52 | 53 | // Don't PasswordPolicy the length and formatter fields if a policy is set 54 | if c.PasswordPolicy != "" { 55 | return nil 56 | } 57 | 58 | // Check for if there's no formatter. 59 | if c.Formatter == "" { 60 | if c.Length < len(passwordComplexityPrefix)+minimumLengthOfComplexString { 61 | return fmt.Errorf("it's not possible to generate a _secure_ password of length %d, please boost length to %d, though Vault recommends higher", 62 | c.Length, minimumLengthOfComplexString+len(passwordComplexityPrefix)) 63 | } 64 | return nil 65 | } 66 | 67 | // Check for if there is a formatter. 68 | if lengthOfPassword(c.Formatter, c.Length) < minimumLengthOfComplexString { 69 | return fmt.Errorf("since the desired length is %d, it isn't possible to generate a sufficiently complex password - please increase desired length or remove characters from the formatter", c.Length) 70 | } 71 | numPwdFields := strings.Count(c.Formatter, pwdFieldTmpl) 72 | if numPwdFields == 0 { 73 | return fmt.Errorf("%s must contain password replacement field of %s", c.Formatter, pwdFieldTmpl) 74 | } 75 | if numPwdFields > 1 { 76 | return fmt.Errorf("%s must contain ONE password replacement field of %s", c.Formatter, pwdFieldTmpl) 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.21.0 2 | ### Jun 2, 2025 3 | 4 | ### Improvements 5 | * Updated dependencies: 6 | * Go version: 1.23.6 -> 1.24.3 7 | * `github.com/go-ldap/ldap/v3` v3.4.8 -> v3.4.11 8 | * `github.com/hashicorp/vault/sdk` v0.15.0 -> v0.17.0 9 | * `golang.org/x/text` v0.22.0 -> v0.25.0 10 | 11 | ## v0.20.0 12 | ### Feb 13, 2025 13 | 14 | ### Improvements: 15 | * Updated dependencies: 16 | * https://github.com/hashicorp/vault-plugin-secrets-ad/pull/132 17 | * https://github.com/hashicorp/vault-plugin-secrets-ad/pull/133 18 | * https://github.com/hashicorp/vault-plugin-secrets-ad/pull/134 19 | 20 | ## v0.19.0 21 | ### Sept 11, 2024 22 | 23 | ### Improvements: 24 | * Updated dependencies: 25 | * `github.com/docker/docker` v25.0.5 -> v25.0.6 26 | * `github.com/hashicorp/go-retryablehttp` v0.7.1 -> v0.7.7 27 | 28 | ## v0.18.0 29 | ### May 21, 2024 30 | 31 | ### IMPROVEMENTS: 32 | * Updated dependencies: 33 | * `github.com/hashicorp/go-plugin` v1.5.2 -> v1.6.0 to enable running the plugin in containers 34 | * `github.com/go-ldap/ldap/v3` v3.4.4 -> v3.4.8 35 | * `golang.org/x/text` v0.14.0 -> v0.15.0 36 | * `github.com/stretchr/testify` v1.8.4 -> v1.9.0 37 | 38 | ## v0.17.0 39 | ### February 1, 2024 40 | 41 | ### IMPROVEMENTS: 42 | * Updated dependencies: 43 | * `github.com/go-errors/errors` v1.5.0 -> v1.5.1 44 | * `github.com/hashicorp/go-hclog` v1.5.0 -> v1.6.2 45 | * `github.com/hashicorp/vault/api` v1.10.0 -> v0.10.0 46 | * `github.com/hashicorp/vault/api` v1.11.0 -> v0.10.2 47 | * `golang.org/x/text` v0.13.0 -> v0.14.0 48 | 49 | ## v0.16.2 50 | ### January 24, 2024 51 | 52 | ### BUG FIXES: 53 | * Revert back to armon/go-metrics [GH-118](https://github.com/hashicorp/vault-plugin-secrets-ad/pull/118) 54 | 55 | ## v0.16.1 56 | ### September 7, 2023 57 | 58 | ### IMPROVEMENTS: 59 | * Updated dependencies: 60 | * `github.com/go-errors/errors` v1.4.2 -> v1.5.0 61 | * `github.com/hashicorp/vault/api` v1.9.1 -> v1.10.0 62 | * `github.com/hashicorp/vault/sdk` v0.9.0 -> v0.10.0 63 | * `github.com/stretchr/testify` v1.8.2 -> v1.8.4 64 | * `golang.org/x/text` v0.9.0 -> v0.13.0 65 | 66 | ## v0.16.0 67 | ### May 24, 2023 68 | 69 | ### IMPROVEMENTS: 70 | 71 | * enable plugin multiplexing [GH-99](https://github.com/hashicorp/vault-plugin-secrets-ad/pull/99) 72 | * update dependencies 73 | * `github.com/hashicorp/vault/api` v1.9.1 74 | * `github.com/hashicorp/vault/sdk` v0.9.0 75 | * `golang.org/x/text` v0.9.0 76 | * `golang.org/x/net` v0.7.0 77 | 78 | ## v0.15.0 79 | ### February 7, 2023 80 | 81 | * Plugin release milestone 82 | 83 | ## v0.14.1 84 | ### December 1, 2022 85 | 86 | ### BUG FIXES: 87 | 88 | * Fix bug where updates to config would fail if password isn't provided [GH-91](https://github.com/hashicorp/vault-plugin-secrets-ad/pull/91) 89 | 90 | ## v0.14.0 91 | ### September 19, 2022 92 | 93 | * Plugin release milestone 94 | 95 | ## v0.13.1 96 | ### June 22, 2022 97 | 98 | ### IMPROVEMENTS: 99 | 100 | * config: set default length only if password policy is missing [GH-85](https://github.com/hashicorp/vault-plugin-secrets-ad/pull/85) 101 | -------------------------------------------------------------------------------- /plugin/path_rotate_creds.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | 11 | "github.com/hashicorp/vault/sdk/framework" 12 | "github.com/hashicorp/vault/sdk/logical" 13 | ) 14 | 15 | const ( 16 | rotateRolePath = "rotate-role/" 17 | ) 18 | 19 | func (b *backend) pathRotateCredentials() *framework.Path { 20 | return &framework.Path{ 21 | Pattern: rotateRolePath + framework.GenericNameRegex("name"), 22 | Fields: map[string]*framework.FieldSchema{ 23 | "name": { 24 | Type: framework.TypeString, 25 | Description: "Name of the static role", 26 | }, 27 | }, 28 | Operations: map[logical.Operation]framework.OperationHandler{ 29 | logical.UpdateOperation: &framework.PathOperation{ 30 | Callback: b.pathRotateCredentialsUpdate, 31 | ForwardPerformanceStandby: true, 32 | ForwardPerformanceSecondary: true, 33 | }, 34 | }, 35 | 36 | HelpSynopsis: pathRotateCredentialsUpdateHelpSyn, 37 | HelpDescription: pathRotateCredentialsUpdateHelpDesc, 38 | } 39 | } 40 | 41 | func (b *backend) pathRotateCredentialsUpdate(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 42 | cred := make(map[string]interface{}) 43 | 44 | config, err := readConfig(ctx, req.Storage) 45 | if err != nil { 46 | return nil, err 47 | } 48 | if config == nil { 49 | return nil, errors.New("the config is currently unset") 50 | } 51 | 52 | roleName := fieldData.Get("name").(string) 53 | 54 | b.credLock.Lock() 55 | defer b.credLock.Unlock() 56 | 57 | role, err := b.readRole(ctx, req.Storage, roleName) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | if role == nil { 63 | return nil, fmt.Errorf("role %s does not exist", roleName) 64 | } 65 | 66 | if !role.LastVaultRotation.IsZero() { 67 | credIfc, found := b.credCache.Get(roleName) 68 | 69 | if found { 70 | b.Logger().Debug("checking cached credential") 71 | cred = credIfc.(map[string]interface{}) 72 | } else { 73 | b.Logger().Debug("checking stored credential") 74 | entry, err := req.Storage.Get(ctx, storageKey+"/"+roleName) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | // If the creds aren't in storage, but roles are and we've created creds before, 80 | // this is an unexpected state and something has gone wrong. 81 | // Let's be explicit and error about this. 82 | if entry == nil { 83 | b.Logger().Warn("should have the creds for %+v but they're not found", role) 84 | } else { 85 | if err := entry.DecodeJSON(&cred); err != nil { 86 | return nil, err 87 | } 88 | b.credCache.SetDefault(roleName, cred) 89 | } 90 | } 91 | } 92 | 93 | _, err = b.generateAndReturnCreds(ctx, config, req.Storage, roleName, role, cred) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return nil, nil 99 | } 100 | 101 | const pathRotateCredentialsUpdateHelpSyn = ` 102 | Request to rotate the role's credentials. 103 | ` 104 | 105 | const pathRotateCredentialsUpdateHelpDesc = ` 106 | This path attempts to rotate the role's credentials. 107 | ` 108 | -------------------------------------------------------------------------------- /plugin/util/secrets_client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package util 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/hashicorp/go-hclog" 11 | 12 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 13 | ) 14 | 15 | func NewSecretsClient(logger hclog.Logger) *SecretsClient { 16 | return &SecretsClient{adClient: client.NewClient(logger)} 17 | } 18 | 19 | // SecretsClient wraps a *activeDirectory.activeDirectoryClient to expose just the common convenience methods needed by the ad secrets backend. 20 | type SecretsClient struct { 21 | adClient *client.Client 22 | } 23 | 24 | func (c *SecretsClient) Get(conf *client.ADConf, serviceAccountName string) (*client.Entry, error) { 25 | filters := map[*client.Field][]string{ 26 | client.FieldRegistry.UserPrincipalName: {serviceAccountName}, 27 | } 28 | 29 | entries, err := c.adClient.Search(conf, conf.UserDN, filters) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if len(entries) == 0 { 35 | return nil, fmt.Errorf("unable to find service account named %s in active directory, searches are case sensitive", serviceAccountName) 36 | } 37 | if len(entries) > 1 { 38 | return nil, fmt.Errorf("expected one matching service account, but received %+v", entries) 39 | } 40 | return entries[0], nil 41 | } 42 | 43 | func (c *SecretsClient) GetPasswordLastSet(conf *client.ADConf, serviceAccountName string) (time.Time, error) { 44 | entry, err := c.Get(conf, serviceAccountName) 45 | if err != nil { 46 | return time.Time{}, err 47 | } 48 | 49 | values, found := entry.Get(client.FieldRegistry.PasswordLastSet) 50 | if !found { 51 | return time.Time{}, fmt.Errorf("%+v lacks a PasswordLastSet field", entry) 52 | } 53 | 54 | if len(values) != 1 { 55 | return time.Time{}, fmt.Errorf("expected only one value for PasswordLastSet, but received %s", values) 56 | } 57 | 58 | ticks := values[0] 59 | if ticks == "0" { 60 | // password has never been rolled in Active Directory, only created 61 | return time.Time{}, nil 62 | } 63 | 64 | t, err := client.ParseTicks(ticks) 65 | if err != nil { 66 | return time.Time{}, err 67 | } 68 | return t, nil 69 | } 70 | 71 | func (c *SecretsClient) UpdatePassword(conf *client.ADConf, serviceAccountName string, newPassword string) error { 72 | filters := map[*client.Field][]string{ 73 | client.FieldRegistry.UserPrincipalName: {serviceAccountName}, 74 | } 75 | return c.adClient.UpdatePassword(conf, conf.UserDN, filters, newPassword) 76 | } 77 | 78 | func (c *SecretsClient) UpdateRootPassword(conf *client.ADConf, bindDN string, newPassword string) error { 79 | filters := map[*client.Field][]string{ 80 | client.FieldRegistry.DistinguishedName: {bindDN}, 81 | } 82 | // Here, use the binddn as the base for the search tree, since it actually may live 83 | // in a separate location from the users it's managing. For example, suppose the root 84 | // user was in a "Security" OU, while the users whose passwords were being managed were 85 | // in a separate, non-overlapping "Accounting" OU. We wouldn't want to search the 86 | // accounting team to rotate the security user's password, we'd want to search the 87 | // security team. 88 | return c.adClient.UpdatePassword(conf, conf.BindDN, filters, newPassword) 89 | } 90 | -------------------------------------------------------------------------------- /plugin/path_creds_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | "time" 10 | 11 | "github.com/go-ldap/ldap/v3" 12 | "github.com/hashicorp/go-hclog" 13 | "github.com/hashicorp/vault/sdk/framework" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | 16 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 17 | ) 18 | 19 | func Test_TTLIsRespected(t *testing.T) { 20 | fakeClient := &thisFake{} 21 | b := newBackend(fakeClient, nil) 22 | ctx := context.Background() 23 | storage := &logical.InmemStorage{} 24 | logger := hclog.Default() 25 | logger.SetLevel(hclog.Debug) 26 | 27 | if err := b.Setup(ctx, &logical.BackendConfig{ 28 | Logger: logger, 29 | }); err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | // Set up the config 34 | config := &configuration{ 35 | PasswordConf: passwordConf{ 36 | /* 37 | This differs from the original config posted by the user 38 | but I have to do it to get a matching TTL on the role. 39 | */ 40 | TTL: 7776000, 41 | MaxTTL: 7776000, 42 | Length: 14, 43 | }, 44 | ADConf: &client.ADConf{}, 45 | } 46 | entry, err := logical.StorageEntryJSON(configStorageKey, config) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | if err := storage.Put(ctx, entry); err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | // Set up the role 55 | createRoleReq := &logical.Request{ 56 | Storage: storage, 57 | } 58 | createRoleFieldData := &framework.FieldData{ 59 | Schema: b.pathRoles().Fields, 60 | Raw: map[string]interface{}{ 61 | "name": "test-role", 62 | "service_account_name": "vault_test2@aaa.bbb.ccc.com", 63 | "ttl": 7776000, // This also differs from the original role posted. 64 | }, 65 | } 66 | 67 | _, err = b.roleUpdateOperation(ctx, createRoleReq, createRoleFieldData) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | // Get creds the first time 73 | readCredsFieldData := &framework.FieldData{ 74 | Schema: b.pathCreds().Fields, 75 | Raw: map[string]interface{}{ 76 | "name": "test-role", 77 | }, 78 | } 79 | readCredsReq := &logical.Request{ 80 | Storage: storage, 81 | } 82 | _, err = b.credReadOperation(ctx, readCredsReq, readCredsFieldData) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | // Get creds another time 88 | _, err = b.credReadOperation(ctx, readCredsReq, readCredsFieldData) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | if fakeClient.numPasswordUpdates > 1 { 94 | t.Fatalf("expected 1 password update but received %d", fakeClient.numPasswordUpdates) 95 | } 96 | } 97 | 98 | type thisFake struct { 99 | numPasswordUpdates int 100 | } 101 | 102 | func (f *thisFake) Get(conf *client.ADConf, serviceAccountName string) (*client.Entry, error) { 103 | entry := &ldap.Entry{} 104 | entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{ 105 | Name: client.FieldRegistry.PasswordLastSet.String(), 106 | Values: []string{"131680504285591921"}, 107 | }) 108 | return client.NewEntry(entry), nil 109 | } 110 | 111 | func (f *thisFake) GetPasswordLastSet(conf *client.ADConf, serviceAccountName string) (time.Time, error) { 112 | f.numPasswordUpdates++ 113 | return time.Date(2019, time.April, 17, 23, 10, 58, 0, time.UTC), nil 114 | } 115 | 116 | func (f *thisFake) UpdatePassword(conf *client.ADConf, serviceAccountName string, newPassword string) error { 117 | return nil 118 | } 119 | 120 | func (f *thisFake) UpdateRootPassword(conf *client.ADConf, bindDN string, newPassword string) error { 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /plugin/rollback.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/hashicorp/vault/sdk/logical" 13 | "github.com/mitchellh/mapstructure" 14 | ) 15 | 16 | const ( 17 | rotateCredentialWAL = "rotateCredentialWAL" 18 | ) 19 | 20 | // rotateCredentialEntry is used to store information in a WAL that can retry a 21 | // credential rotation in the event of partial failure. 22 | type rotateCredentialEntry struct { 23 | LastVaultRotation time.Time `json:"last_vault_rotation"` 24 | LastPassword string `json:"last_password"` 25 | CurrentPassword string `json:"current_password"` 26 | RoleName string `json:"name"` 27 | ServiceAccountName string `json:"service_account_name"` 28 | TTL int `json:"ttl"` 29 | } 30 | 31 | func (b *backend) walRollback(ctx context.Context, req *logical.Request, kind string, data interface{}) error { 32 | switch kind { 33 | case rotateCredentialWAL: 34 | return b.handleRotateCredentialRollback(ctx, req.Storage, data) 35 | default: 36 | return fmt.Errorf("unknown WAL entry kind %q", kind) 37 | } 38 | } 39 | 40 | func (b *backend) handleRotateCredentialRollback(ctx context.Context, storage logical.Storage, data interface{}) error { 41 | var wal rotateCredentialEntry 42 | if err := mapstructure.WeakDecode(data, &wal); err != nil { 43 | return err 44 | } 45 | 46 | if wal.CurrentPassword == "" { 47 | b.Logger().Warn("WAL does not contain a password for service account") 48 | return nil 49 | } 50 | 51 | // Check creds for deltas. Exit if creds and WAL are the same. 52 | path := fmt.Sprintf("%s/%s", storageKey, wal.RoleName) 53 | credEntry, err := storage.Get(ctx, path) 54 | if err == nil && credEntry != nil { 55 | cred := make(map[string]interface{}) 56 | err := credEntry.DecodeJSON(&cred) 57 | if err == nil && cred != nil { 58 | currentPassword := cred["current_password"] 59 | lastPassword := cred["last_password"] 60 | 61 | if currentPassword == wal.CurrentPassword && lastPassword == wal.LastPassword { 62 | return nil 63 | } 64 | } 65 | } 66 | 67 | role := &backendRole{ 68 | ServiceAccountName: wal.ServiceAccountName, 69 | TTL: wal.TTL, 70 | LastVaultRotation: wal.LastVaultRotation, 71 | } 72 | 73 | if err := b.writeRoleToStorage(ctx, storage, wal.RoleName, role); err != nil { 74 | return err 75 | } 76 | 77 | // Cache the full role to minimize Vault storage calls. 78 | b.roleCache.SetDefault(wal.RoleName, role) 79 | 80 | conf, err := readConfig(ctx, storage) 81 | if err != nil { 82 | return err 83 | } 84 | if conf == nil { 85 | return errors.New("the config is currently unset") 86 | } 87 | 88 | if err := b.client.UpdatePassword(conf.ADConf, role.ServiceAccountName, wal.CurrentPassword); err != nil { 89 | return err 90 | } 91 | 92 | // Although a service account name is typically my_app@example.com, 93 | // the username it uses is just my_app, or everything before the @. 94 | username, err := getUsername(role.ServiceAccountName) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | b.credLock.Lock() 100 | defer b.credLock.Unlock() 101 | 102 | cred := map[string]interface{}{ 103 | "username": username, 104 | "current_password": wal.CurrentPassword, 105 | } 106 | 107 | if wal.LastPassword != "" { 108 | cred["last_password"] = wal.LastPassword 109 | } 110 | 111 | // Cache and save the cred. 112 | path = fmt.Sprintf("%s/%s", storageKey, wal.RoleName) 113 | entry, err := logical.StorageEntryJSON(path, cred) 114 | if err != nil { 115 | return err 116 | } 117 | if err := storage.Put(ctx, entry); err != nil { 118 | return err 119 | } 120 | 121 | b.credCache.SetDefault(wal.RoleName, cred) 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /plugin/backend.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "sync" 9 | "time" 10 | 11 | "github.com/hashicorp/vault/sdk/framework" 12 | "github.com/hashicorp/vault/sdk/helper/locksutil" 13 | "github.com/hashicorp/vault/sdk/logical" 14 | "github.com/patrickmn/go-cache" 15 | 16 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 17 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/util" 18 | ) 19 | 20 | func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { 21 | backend := newBackend(util.NewSecretsClient(conf.Logger), conf.System) 22 | if err := backend.Setup(ctx, conf); err != nil { 23 | return nil, err 24 | } 25 | return backend, nil 26 | } 27 | 28 | func newBackend(client secretsClient, passwordGenerator passwordGenerator) *backend { 29 | adBackend := &backend{ 30 | client: client, 31 | roleCache: cache.New(roleCacheExpiration, roleCacheCleanup), 32 | credCache: cache.New(credCacheExpiration, credCacheCleanup), 33 | rotateRootLock: new(int32), 34 | checkOutHandler: &checkOutHandler{ 35 | client: client, 36 | passwordGenerator: passwordGenerator, 37 | }, 38 | checkOutLocks: locksutil.CreateLocks(), 39 | } 40 | adBackend.Backend = &framework.Backend{ 41 | Help: backendHelp, 42 | Paths: []*framework.Path{ 43 | adBackend.pathConfig(), 44 | adBackend.pathRoles(), 45 | adBackend.pathListRoles(), 46 | adBackend.pathCreds(), 47 | adBackend.pathRotateRootCredentials(), 48 | adBackend.pathRotateCredentials(), 49 | 50 | // The following paths are for AD credential checkout. 51 | adBackend.pathSetCheckIn(), 52 | adBackend.pathSetManageCheckIn(), 53 | adBackend.pathSetCheckOut(), 54 | adBackend.pathSetStatus(), 55 | adBackend.pathSets(), 56 | adBackend.pathListSets(), 57 | }, 58 | PathsSpecial: &logical.Paths{ 59 | SealWrapStorage: []string{ 60 | configPath, 61 | credPrefix, 62 | }, 63 | }, 64 | Invalidate: adBackend.Invalidate, 65 | BackendType: logical.TypeLogical, 66 | Secrets: []*framework.Secret{ 67 | adBackend.secretAccessKeys(), 68 | }, 69 | WALRollback: adBackend.walRollback, 70 | WALRollbackMinAge: 1 * time.Minute, 71 | } 72 | return adBackend 73 | } 74 | 75 | type backend struct { 76 | *framework.Backend 77 | 78 | client secretsClient 79 | 80 | roleCache *cache.Cache 81 | credCache *cache.Cache 82 | credLock sync.Mutex 83 | rotateRootLock *int32 84 | 85 | checkOutHandler *checkOutHandler 86 | // checkOutLocks are used for avoiding races 87 | // when working with sets through the check-out system. 88 | checkOutLocks []*locksutil.LockEntry 89 | } 90 | 91 | func (b *backend) Invalidate(ctx context.Context, key string) { 92 | b.invalidateRole(ctx, key) 93 | b.invalidateCred(ctx, key) 94 | } 95 | 96 | // Wraps the *util.SecretsClient in an interface to support testing. 97 | type secretsClient interface { 98 | Get(conf *client.ADConf, serviceAccountName string) (*client.Entry, error) 99 | GetPasswordLastSet(conf *client.ADConf, serviceAccountName string) (time.Time, error) 100 | UpdatePassword(conf *client.ADConf, serviceAccountName string, newPassword string) error 101 | UpdateRootPassword(conf *client.ADConf, bindDN string, newPassword string) error 102 | } 103 | 104 | const backendHelp = ` 105 | The Active Directory (AD) secrets engine rotates AD passwords dynamically, 106 | and is designed for a high-load environment where many instances may be accessing 107 | a shared password simultaneously. With a simple set up and a simple creds API, 108 | it doesn't require instances to be manually registered in advance to gain access. 109 | As long as access has been granted to the creds path via a method like 110 | AppRole, they're available. 111 | 112 | Passwords are lazily rotated based on preset TTLs and can have a length configured to meet 113 | your needs. 114 | ` 115 | -------------------------------------------------------------------------------- /plugin/passwords_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "regexp" 10 | "testing" 11 | ) 12 | 13 | func TestGeneratePassword(t *testing.T) { 14 | type testCase struct { 15 | passConf passwordConf 16 | generator passwordGenerator 17 | 18 | passwordAssertion func(t *testing.T, password string) 19 | expectErr bool 20 | } 21 | 22 | tests := map[string]testCase{ 23 | "missing configs": { 24 | passConf: passwordConf{ 25 | Length: 0, 26 | Formatter: "", 27 | PasswordPolicy: "", 28 | }, 29 | generator: nil, 30 | 31 | passwordAssertion: assertNoPassword, 32 | expectErr: true, 33 | }, 34 | "policy failure": { 35 | passConf: passwordConf{ 36 | PasswordPolicy: "testpolicy", 37 | }, 38 | generator: makePasswordGenerator("", fmt.Errorf("test error")), 39 | passwordAssertion: assertNoPassword, 40 | expectErr: true, 41 | }, 42 | "successful policy": { 43 | passConf: passwordConf{ 44 | PasswordPolicy: "testpolicy", 45 | }, 46 | generator: makePasswordGenerator("testpassword", nil), 47 | passwordAssertion: assertPassword("testpassword"), 48 | expectErr: false, 49 | }, 50 | "deprecated with no formatter": { 51 | passConf: passwordConf{ 52 | Length: 50, 53 | }, 54 | passwordAssertion: assertPasswordRegex( 55 | fmt.Sprintf("^%s[a-zA-Z0-9]{%d}$", 56 | regexp.QuoteMeta(passwordComplexityPrefix), 57 | 50-len(passwordComplexityPrefix), 58 | ), 59 | ), 60 | expectErr: false, 61 | }, 62 | "deprecated with formatter prefix": { 63 | passConf: passwordConf{ 64 | Length: 50, 65 | Formatter: "foobar{{PASSWORD}}", 66 | }, 67 | passwordAssertion: assertPasswordRegex("^foobar[a-zA-Z0-9]{44}$"), 68 | expectErr: false, 69 | }, 70 | "deprecated with formatter suffix": { 71 | passConf: passwordConf{ 72 | Length: 50, 73 | Formatter: "{{PASSWORD}}foobar", 74 | }, 75 | passwordAssertion: assertPasswordRegex("^[a-zA-Z0-9]{44}foobar$"), 76 | expectErr: false, 77 | }, 78 | "deprecated with formatter prefix and suffix": { 79 | passConf: passwordConf{ 80 | Length: 50, 81 | Formatter: "foo{{PASSWORD}}bar", 82 | }, 83 | passwordAssertion: assertPasswordRegex("^foo[a-zA-Z0-9]{44}bar$"), 84 | expectErr: false, 85 | }, 86 | } 87 | 88 | for name, test := range tests { 89 | t.Run(name, func(t *testing.T) { 90 | password, err := GeneratePassword(context.Background(), test.passConf, test.generator) 91 | if test.expectErr && err == nil { 92 | t.Fatalf("err expected, got nil") 93 | } 94 | if !test.expectErr && err != nil { 95 | t.Fatalf("no error expected, got: %s", err) 96 | } 97 | test.passwordAssertion(t, password) 98 | }) 99 | } 100 | } 101 | 102 | func assertNoPassword(t *testing.T, password string) { 103 | t.Helper() 104 | if password != "" { 105 | t.Fatalf("password should be empty") 106 | } 107 | } 108 | 109 | func assertPassword(expectedPassword string) func(*testing.T, string) { 110 | return func(t *testing.T, password string) { 111 | t.Helper() 112 | if password != expectedPassword { 113 | t.Fatalf("Expected password %q but was %q", expectedPassword, password) 114 | } 115 | } 116 | } 117 | 118 | func assertPasswordRegex(rawRegex string) func(*testing.T, string) { 119 | re := regexp.MustCompile(rawRegex) 120 | return func(t *testing.T, password string) { 121 | t.Helper() 122 | if !re.MatchString(password) { 123 | t.Fatalf("Password %q does not match regexp %q", password, rawRegex) 124 | } 125 | } 126 | } 127 | 128 | type fakeGenerator struct { 129 | password string 130 | err error 131 | } 132 | 133 | func (g fakeGenerator) GeneratePasswordFromPolicy(_ context.Context, _ string) (password string, err error) { 134 | return g.password, g.err 135 | } 136 | 137 | func makePasswordGenerator(returnedPass string, returnedErr error) passwordGenerator { 138 | return fakeGenerator{ 139 | password: returnedPass, 140 | err: returnedErr, 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vault Plugin: Active Directory Secrets Backend 2 | 3 | This is a standalone backend plugin for use with [Hashicorp Vault](https://www.github.com/hashicorp/vault). 4 | This plugin provides Active Directory functionality to Vault. 5 | 6 | **Please note**: We take Vault's security and our users' trust very seriously. If you believe you have found a security issue in Vault, _please responsibly disclose_ by contacting us at [security@hashicorp.com](mailto:security@hashicorp.com). 7 | 8 | ## Quick Links 9 | - Vault Website: https://www.vaultproject.io 10 | - Active Directory Docs: https://developer.hashicorp.com/vault/docs/secrets/ad 11 | - Main Project Github: https://www.github.com/hashicorp/vault 12 | 13 | ## Getting Started 14 | 15 | This is a [Vault plugin](https://developer.hashicorp.com/vault/docs/plugins) 16 | and is meant to work with Vault. This guide assumes you have already installed Vault 17 | and have a basic understanding of how Vault works. 18 | 19 | Otherwise, first read this guide on how to [get started with Vault](https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-install). 20 | 21 | To learn specifically about how plugins work, see documentation on [Vault plugins](https://developer.hashicorp.com/vault/docs/plugins). 22 | 23 | ## Usage 24 | 25 | Please see [documentation for the plugin](https://developer.hashicorp.com/vault/docs/secrets/ad) 26 | on the Vault website. 27 | 28 | This plugin is currently built into Vault and by default is accessed 29 | at `ad`. To enable this in a running Vault server: 30 | 31 | ```sh 32 | $ vault secrets enable ad 33 | Success! Enabled the ad secrets engine at: ad/ 34 | ``` 35 | 36 | Additionally starting with Vault 0.10 this backend is by default mounted 37 | at `secret/`. 38 | 39 | ## Developing 40 | 41 | If you wish to work on this plugin, you'll first need 42 | [Go](https://www.golang.org) installed on your machine 43 | (version 1.17+ is *required*). 44 | 45 | For local dev first make sure Go is properly installed, including 46 | setting up a [GOPATH](https://golang.org/doc/code.html#GOPATH). 47 | 48 | To compile a development version of this plugin, run `make` or `make dev`. 49 | This will put the plugin binary in the `bin` and `$GOPATH/bin` folders. `dev` 50 | mode will only generate the binary for your platform and is faster: 51 | 52 | ```sh 53 | $ make 54 | $ make dev 55 | ``` 56 | 57 | Put the plugin binary into a location of your choice. This directory 58 | will be specified as the [`plugin_directory`](https://developer.hashicorp.com/vault/docs/configuration#plugin_directory) 59 | in the Vault config used to start the server. 60 | 61 | ```hcl 62 | plugin_directory = "path/to/plugin/directory" 63 | ``` 64 | 65 | Start a Vault server with this config file: 66 | ```sh 67 | $ vault server -config=path/to/config.json ... 68 | ... 69 | ``` 70 | 71 | Once the server is started, register the plugin in the Vault server's [plugin catalog](https://developer.hashicorp.com/vault/docs/plugins/plugin-architecture#plugin-catalog): 72 | 73 | ```sh 74 | $ vault plugin register \ 75 | -sha256= \ 76 | -command="vault-plugin-secrets-ad" \ 77 | secret \ 78 | custom-ad 79 | ``` 80 | 81 | Note you should generate a new sha256 checksum if you have made changes 82 | to the plugin. Example using openssl: 83 | 84 | ```sh 85 | openssl dgst -sha256 $GOPATH/vault-plugin-secrets-ad 86 | ... 87 | SHA256(.../go/bin/vault-plugin-secrets-ad)= 896c13c0f5305daed381952a128322e02bc28a57d0c862a78cbc2ea66e8c6fa1 88 | ``` 89 | 90 | Enable the secrets plugin backend using the secrets enable plugin command: 91 | 92 | ```sh 93 | $ vault secrets enable custom-ad 94 | ... 95 | 96 | Successfully enabled 'plugin' at 'custom-ad'! 97 | ``` 98 | 99 | #### Tests 100 | 101 | If you are developing this plugin and want to verify it is still 102 | functioning (and you haven't broken anything else), we recommend 103 | running the tests. 104 | 105 | To run the tests, run the following: 106 | 107 | ```sh 108 | $ make test 109 | ``` 110 | 111 | You can also specify a `TESTARGS` variable to filter tests like so: 112 | 113 | ```sh 114 | $ make test TESTARGS='--run=TestConfig' 115 | ``` 116 | 117 | -------------------------------------------------------------------------------- /plugin/path_rotate_root.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "math" 11 | "sync/atomic" 12 | "time" 13 | 14 | "github.com/hashicorp/vault/sdk/framework" 15 | "github.com/hashicorp/vault/sdk/logical" 16 | ) 17 | 18 | const rotateRootPath = "rotate-root" 19 | 20 | func (b *backend) pathRotateRootCredentials() *framework.Path { 21 | return &framework.Path{ 22 | Pattern: rotateRootPath, 23 | Operations: map[logical.Operation]framework.OperationHandler{ 24 | logical.ReadOperation: &framework.PathOperation{ 25 | Callback: b.pathRotateRootCredentialsUpdate, 26 | ForwardPerformanceStandby: true, 27 | ForwardPerformanceSecondary: true, 28 | }, 29 | logical.UpdateOperation: &framework.PathOperation{ 30 | Callback: b.pathRotateRootCredentialsUpdate, 31 | ForwardPerformanceStandby: true, 32 | ForwardPerformanceSecondary: true, 33 | }, 34 | }, 35 | 36 | HelpSynopsis: pathRotateCredentialsUpdateHelpSyn, 37 | HelpDescription: pathRotateCredentialsUpdateHelpDesc, 38 | } 39 | } 40 | 41 | func (b *backend) pathRotateRootCredentialsUpdate(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { 42 | engineConf, err := readConfig(ctx, req.Storage) 43 | if err != nil { 44 | return nil, err 45 | } 46 | if engineConf == nil { 47 | return nil, errors.New("the config is currently unset") 48 | } 49 | 50 | newPassword, err := GeneratePassword(ctx, engineConf.PasswordConf, b.System()) 51 | if err != nil { 52 | return nil, err 53 | } 54 | oldPassword := engineConf.ADConf.BindPassword 55 | 56 | if !atomic.CompareAndSwapInt32(b.rotateRootLock, 0, 1) { 57 | resp := &logical.Response{} 58 | resp.AddWarning("Root password rotation is already in progress.") 59 | return resp, nil 60 | } 61 | defer atomic.CompareAndSwapInt32(b.rotateRootLock, 1, 0) 62 | 63 | // Update the password remotely. 64 | if err := b.client.UpdateRootPassword(engineConf.ADConf, engineConf.ADConf.BindDN, newPassword); err != nil { 65 | return nil, err 66 | } 67 | engineConf.ADConf.BindPassword = newPassword 68 | 69 | // Update the password locally. 70 | if pwdStoringErr := writeConfig(ctx, req.Storage, engineConf); pwdStoringErr != nil { 71 | // We were unable to store the new password locally. We can't continue in this state because we won't be able 72 | // to roll any passwords, including our own to get back into a state of working. So, we need to roll back to 73 | // the last password we successfully got into storage. 74 | if rollbackErr := b.rollBackRootPassword(ctx, engineConf, oldPassword); rollbackErr != nil { 75 | return nil, fmt.Errorf("unable to store new password due to %s and unable to return to previous password due to %s, configure a new binddn and bindpass to restore active directory function", pwdStoringErr, rollbackErr) 76 | } 77 | return nil, fmt.Errorf("unable to update password due to storage err: %s", pwdStoringErr) 78 | } 79 | // Respond with a 204. 80 | return nil, nil 81 | } 82 | 83 | // rollBackPassword uses naive exponential backoff to retry updating to an old password, 84 | // because Active Directory may still be propagating the previous password change. 85 | func (b *backend) rollBackRootPassword(ctx context.Context, engineConf *configuration, oldPassword string) error { 86 | var err error 87 | for i := 0; i < 10; i++ { 88 | waitSeconds := math.Pow(float64(i), 2) 89 | timer := time.NewTimer(time.Duration(waitSeconds) * time.Second) 90 | select { 91 | case <-timer.C: 92 | case <-ctx.Done(): 93 | // Outer environment is closing. 94 | return fmt.Errorf("unable to roll back password because enclosing environment is shutting down") 95 | } 96 | if err = b.client.UpdateRootPassword(engineConf.ADConf, engineConf.ADConf.BindDN, oldPassword); err == nil { 97 | // Success. 98 | return nil 99 | } 100 | } 101 | // Failure after looping. 102 | return err 103 | } 104 | 105 | const pathRotateRootCredentialsUpdateHelpSyn = ` 106 | Request to rotate the root credentials. 107 | ` 108 | 109 | const pathRotateRootCredentialsUpdateHelpDesc = ` 110 | This path attempts to rotate the root credentials. 111 | ` 112 | -------------------------------------------------------------------------------- /plugin/client/fieldregistry.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "reflect" 8 | ) 9 | 10 | // FieldRegistry is designed to look and feel 11 | // like an enum from another language like Python. 12 | // 13 | // Example: Accessing constants 14 | // 15 | // FieldRegistry.AccountExpires 16 | // FieldRegistry.BadPasswordCount 17 | // 18 | // Example: Utility methods 19 | // 20 | // FieldRegistry.List() 21 | // FieldRegistry.Parse("givenName") 22 | var FieldRegistry = newFieldRegistry() 23 | 24 | // newFieldRegistry iterates through all the fields in the registry, 25 | // pulls their ldap strings, and sets up each field to contain its ldap string 26 | func newFieldRegistry() *fieldRegistry { 27 | reg := &fieldRegistry{} 28 | vOfReg := reflect.ValueOf(reg) 29 | 30 | registryFields := vOfReg.Elem() 31 | for i := 0; i < registryFields.NumField(); i++ { 32 | 33 | if registryFields.Field(i).Kind() == reflect.Ptr { 34 | 35 | field := registryFields.Type().Field(i) 36 | ldapString := field.Tag.Get("ldap") 37 | ldapField := &Field{ldapString} 38 | vOfLDAPField := reflect.ValueOf(ldapField) 39 | 40 | registryFields.FieldByName(field.Name).Set(vOfLDAPField) 41 | 42 | reg.fieldList = append(reg.fieldList, ldapField) 43 | } 44 | } 45 | return reg 46 | } 47 | 48 | // fieldRegistry isn't currently intended to be an exhaustive list - 49 | // there are more fields in ActiveDirectory. However, these are the ones 50 | // that may be useful to Vault. Feel free to add to this list! 51 | type fieldRegistry struct { 52 | AccountExpires *Field `ldap:"accountExpires"` 53 | AdminCount *Field `ldap:"adminCount"` 54 | BadPasswordCount *Field `ldap:"badPwdCount"` 55 | BadPasswordTime *Field `ldap:"badPasswordTime"` 56 | CodePage *Field `ldap:"codePage"` 57 | CommonName *Field `ldap:"cn"` 58 | CountryCode *Field `ldap:"countryCode"` 59 | DisplayName *Field `ldap:"displayName"` 60 | DistinguishedName *Field `ldap:"distinguishedName"` 61 | DomainComponent *Field `ldap:"dc"` 62 | DomainName *Field `ldap:"dn"` 63 | DSCorePropogationData *Field `ldap:"dSCorePropagationData"` 64 | GivenName *Field `ldap:"givenName"` 65 | GroupType *Field `ldap:"groupType"` 66 | Initials *Field `ldap:"initials"` 67 | InstanceType *Field `ldap:"instanceType"` 68 | LastLogoff *Field `ldap:"lastLogoff"` 69 | LastLogon *Field `ldap:"lastLogon"` 70 | LastLogonTimestamp *Field `ldap:"lastLogonTimestamp"` 71 | LockoutTime *Field `ldap:"lockoutTime"` 72 | LogonCount *Field `ldap:"logonCount"` 73 | MemberOf *Field `ldap:"memberOf"` 74 | Name *Field `ldap:"name"` 75 | ObjectCategory *Field `ldap:"objectCategory"` 76 | ObjectClass *Field `ldap:"objectClass"` 77 | ObjectGUID *Field `ldap:"objectGUID"` 78 | ObjectSID *Field `ldap:"objectSid"` 79 | OrganizationalUnit *Field `ldap:"ou"` 80 | PasswordLastSet *Field `ldap:"pwdLastSet"` 81 | PrimaryGroupID *Field `ldap:"primaryGroupID"` 82 | SAMAccountName *Field `ldap:"sAMAccountName"` 83 | SAMAccountType *Field `ldap:"sAMAccountType"` 84 | Surname *Field `ldap:"sn"` 85 | UnicodePassword *Field `ldap:"unicodePwd"` 86 | UpdateSequenceNumberChanged *Field `ldap:"uSNChanged"` 87 | UpdateSequenceNumberCreated *Field `ldap:"uSNCreated"` 88 | UserAccountControl *Field `ldap:"userAccountControl"` 89 | UserPrincipalName *Field `ldap:"userPrincipalName"` 90 | WhenCreated *Field `ldap:"whenCreated"` 91 | WhenChanged *Field `ldap:"whenChanged"` 92 | 93 | fieldList []*Field 94 | } 95 | 96 | func (r *fieldRegistry) List() []*Field { 97 | return r.fieldList 98 | } 99 | 100 | func (r *fieldRegistry) Parse(s string) *Field { 101 | for _, f := range r.List() { 102 | if f.String() == s { 103 | return f 104 | } 105 | } 106 | return nil 107 | } 108 | 109 | type Field struct { 110 | str string 111 | } 112 | 113 | func (f *Field) String() string { 114 | return f.str 115 | } 116 | -------------------------------------------------------------------------------- /plugin/checkout_handler_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/hashicorp/vault/sdk/logical" 12 | ) 13 | 14 | func setup() (context.Context, logical.Storage, string, *CheckOut) { 15 | ctx := context.Background() 16 | storage := &logical.InmemStorage{} 17 | serviceAccountName := "becca@example.com" 18 | checkOut := &CheckOut{ 19 | BorrowerEntityID: "entity-id", 20 | BorrowerClientToken: "client-token", 21 | } 22 | config := &configuration{ 23 | PasswordConf: passwordConf{ 24 | Length: 14, 25 | }, 26 | } 27 | entry, err := logical.StorageEntryJSON(configStorageKey, config) 28 | if err != nil { 29 | panic(err) 30 | } 31 | if err := storage.Put(ctx, entry); err != nil { 32 | panic(err) 33 | } 34 | return ctx, storage, serviceAccountName, checkOut 35 | } 36 | 37 | func TestCheckOutHandlerStorageLayer(t *testing.T) { 38 | ctx, storage, serviceAccountName, testCheckOut := setup() 39 | 40 | storageHandler := &checkOutHandler{ 41 | client: &fakeSecretsClient{}, 42 | } 43 | 44 | // Service accounts must initially be checked in to the library 45 | if err := storageHandler.CheckIn(ctx, storage, serviceAccountName); err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | // If we try to check something out for the first time, it should succeed. 50 | if err := storageHandler.CheckOut(ctx, storage, serviceAccountName, testCheckOut); err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | // We should have the testCheckOut in storage now. 55 | storedCheckOut, err := storageHandler.LoadCheckOut(ctx, storage, serviceAccountName) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | if storedCheckOut == nil { 60 | t.Fatal("storedCheckOut should not be nil") 61 | } 62 | if !reflect.DeepEqual(testCheckOut, storedCheckOut) { 63 | t.Fatalf(`expected %+v to be equal to %+v`, testCheckOut, storedCheckOut) 64 | } 65 | 66 | // If we try to check something out that's already checked out, we should 67 | // get a CurrentlyCheckedOutErr. 68 | if err := storageHandler.CheckOut(ctx, storage, serviceAccountName, testCheckOut); err == nil { 69 | t.Fatal("expected err but received none") 70 | } else if err != errCheckedOut { 71 | t.Fatalf("expected errCheckedOut, but received %s", err) 72 | } 73 | 74 | // If we try to check something in, it should succeed. 75 | if err := storageHandler.CheckIn(ctx, storage, serviceAccountName); err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | // We should no longer have the testCheckOut in storage. 80 | storedCheckOut, err = storageHandler.LoadCheckOut(ctx, storage, serviceAccountName) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | if !storedCheckOut.IsAvailable { 85 | t.Fatal("storedCheckOut should be nil") 86 | } 87 | 88 | // If we try to check it in again, it should have the same behavior. 89 | if err := storageHandler.CheckIn(ctx, storage, serviceAccountName); err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | // If we check it out again, it should succeed. 94 | if err := storageHandler.CheckOut(ctx, storage, serviceAccountName, testCheckOut); err != nil { 95 | t.Fatal(err) 96 | } 97 | } 98 | 99 | func TestPasswordHandlerInterfaceFulfillment(t *testing.T) { 100 | ctx, storage, serviceAccountName, checkOut := setup() 101 | 102 | passwordHandler := &checkOutHandler{ 103 | client: &fakeSecretsClient{}, 104 | } 105 | 106 | // We must always start managing a service account by checking it in. 107 | if err := passwordHandler.CheckIn(ctx, storage, serviceAccountName); err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | // There should be no error during check-out. 112 | if err := passwordHandler.CheckOut(ctx, storage, serviceAccountName, checkOut); err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | // The password should get rotated successfully during check-in. 117 | origPassword, err := retrievePassword(ctx, storage, serviceAccountName) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | if err := passwordHandler.CheckIn(ctx, storage, serviceAccountName); err != nil { 122 | t.Fatal(err) 123 | } 124 | currPassword, err := retrievePassword(ctx, storage, serviceAccountName) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | if currPassword == "" || currPassword == origPassword { 129 | t.Fatal("expected password, but received none") 130 | } 131 | 132 | // There should be no error during delete and the password should be deleted. 133 | if err := passwordHandler.Delete(ctx, storage, serviceAccountName); err != nil { 134 | t.Fatal(err) 135 | } 136 | 137 | currPassword, err = retrievePassword(ctx, storage, serviceAccountName) 138 | if err != errNotFound { 139 | t.Fatal("expected errNotFound") 140 | } 141 | 142 | checkOut, err = passwordHandler.LoadCheckOut(ctx, storage, serviceAccountName) 143 | if err != errNotFound { 144 | t.Fatal("expected err not found") 145 | } 146 | if checkOut != nil { 147 | t.Fatal("expected checkOut to be nil") 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /plugin/path_config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/mitchellh/mapstructure" 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/hashicorp/vault/sdk/framework" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | ) 16 | 17 | var ( 18 | ctx = context.Background() 19 | storage = &logical.InmemStorage{} 20 | ) 21 | 22 | func TestCacheReader(t *testing.T) { 23 | 24 | // we should start with no config 25 | config, err := readConfig(ctx, storage) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | if config != nil { 30 | t.Fatal("config should be nil") 31 | } 32 | 33 | req := &logical.Request{ 34 | Operation: logical.UpdateOperation, 35 | Path: configPath, 36 | Storage: storage, 37 | } 38 | 39 | // submit a minimal config so we can check that we're using safe defaults 40 | fieldData := &framework.FieldData{ 41 | Schema: testBackend.pathConfig().Fields, 42 | Raw: map[string]interface{}{ 43 | "binddn": "tester", 44 | "password": "pa$$w0rd", 45 | "urls": "ldap://138.91.247.105", 46 | "userdn": "example,com", 47 | }, 48 | } 49 | 50 | _, err = testBackend.configUpdateOperation(ctx, req, fieldData) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | // now that we've updated the config, we should be able to configReadOperation it 56 | config, err = readConfig(ctx, storage) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | if config == nil { 61 | t.Fatal("config shouldn't be nil") 62 | } 63 | 64 | if config.ADConf.BindDN != "tester" { 65 | t.Fatal("returned config is not populated as expected") 66 | } 67 | if config.ADConf.TLSMinVersion != defaultTLSVersion { 68 | t.Fatal("we should be defaulting to " + defaultTLSVersion) 69 | } 70 | if config.ADConf.TLSMaxVersion != defaultTLSVersion { 71 | t.Fatal("we should be defaulting to " + defaultTLSVersion) 72 | } 73 | if config.ADConf.InsecureTLS { 74 | t.Fatal("insecure tls should be off by default") 75 | } 76 | 77 | req = &logical.Request{ 78 | Operation: logical.DeleteOperation, 79 | Path: configPath, 80 | Storage: storage, 81 | } 82 | 83 | _, err = testBackend.configDeleteOperation(ctx, req, nil) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | 88 | // now that we've deleted the config, it should be unset again 89 | config, err = readConfig(ctx, storage) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | if config != nil { 94 | t.Fatal("config should be nil") 95 | } 96 | } 97 | 98 | func TestConfig_PasswordLength(t *testing.T) { 99 | 100 | // we should start with no config 101 | config, err := readConfig(ctx, storage) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | if config != nil { 106 | t.Fatal("config should be nil") 107 | } 108 | 109 | req := &logical.Request{ 110 | Operation: logical.UpdateOperation, 111 | Path: configPath, 112 | Storage: storage, 113 | } 114 | 115 | tests := []struct { 116 | name string 117 | rawFieldData map[string]interface{} 118 | wantErr bool 119 | }{ 120 | { 121 | "length provided", 122 | map[string]interface{}{ 123 | "length": 32, 124 | }, 125 | false, 126 | }, 127 | { 128 | "password policy provided", 129 | map[string]interface{}{ 130 | "password_policy": "foo", 131 | }, 132 | false, 133 | }, 134 | { 135 | "no length or password policy provided", 136 | nil, 137 | false, 138 | }, 139 | { 140 | "both length and password policy provided", 141 | map[string]interface{}{ 142 | "password_policy": "foo", 143 | "length": 32, 144 | }, 145 | true, 146 | }, 147 | } 148 | 149 | for _, tt := range tests { 150 | t.Run(tt.name, func(t *testing.T) { 151 | // Start common config fields and append what we need to test against 152 | fieldData := &framework.FieldData{ 153 | Schema: testBackend.pathConfig().Fields, 154 | Raw: map[string]interface{}{ 155 | "binddn": "tester", 156 | "password": "pa$$w0rd", 157 | "urls": "ldap://138.91.247.105", 158 | "userdn": "example,com", 159 | }, 160 | } 161 | 162 | for k, v := range tt.rawFieldData { 163 | fieldData.Raw[k] = v 164 | } 165 | 166 | _, err = testBackend.configUpdateOperation(ctx, req, fieldData) 167 | assert.Equal(t, tt.wantErr, err != nil) 168 | 169 | if tt.wantErr && err != nil { 170 | return 171 | } 172 | 173 | config, err := readConfig(ctx, storage) 174 | assert.NoError(t, err) 175 | 176 | var actual map[string]interface{} 177 | 178 | cfg := &mapstructure.DecoderConfig{ 179 | Result: &actual, 180 | TagName: "json", 181 | } 182 | decoder, err := mapstructure.NewDecoder(cfg) 183 | assert.NoError(t, err) 184 | err = decoder.Decode(config.PasswordConf) 185 | assert.NoError(t, err) 186 | 187 | for k, v := range tt.rawFieldData { 188 | assert.Contains(t, actual, k) 189 | assert.Equal(t, actual[k], v) 190 | } 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /plugin/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "testing" 10 | ) 11 | 12 | // These json snippets are only used in our internal storage, 13 | // they aren't presented externally. 14 | const ( 15 | samplePreviousConfJson = `{ 16 | "PasswordConf": { 17 | "ttl": 1, 18 | "max_ttl": 1, 19 | "length": 1, 20 | "formatter": "something" 21 | }, 22 | "ADConf": { 23 | "url": "www.somewhere.com", 24 | "userdn": "userdn", 25 | "groupdn": "groupdn", 26 | "groupfilter": "groupFilter", 27 | "groupattr": "groupattr", 28 | "upndomain": "upndomain", 29 | "userattr": "", 30 | "certificate": "", 31 | "insecure_tls": false, 32 | "starttls": false, 33 | "binddn": "", 34 | "bindpass": "", 35 | "deny_null_bind": false, 36 | "discoverdn": false, 37 | "tls_min_version": "", 38 | "tls_max_version": "" 39 | } 40 | }` 41 | 42 | sampleCurrentConfJson = `{ 43 | "PasswordConf": { 44 | "ttl": 1, 45 | "max_ttl": 1, 46 | "length": 1, 47 | "formatter": "something" 48 | }, 49 | "ADConf": { 50 | "url": "www.somewhere.com", 51 | "userdn": "userdn", 52 | "groupdn": "groupdn", 53 | "groupfilter": "groupFilter", 54 | "groupattr": "groupattr", 55 | "upndomain": "upndomain", 56 | "userattr": "", 57 | "certificate": "", 58 | "insecure_tls": false, 59 | "starttls": false, 60 | "binddn": "", 61 | "bindpass": "", 62 | "deny_null_bind": false, 63 | "discoverdn": false, 64 | "tls_min_version": "", 65 | "tls_max_version": "", 66 | "last_bind_password": "foo" 67 | } 68 | }` 69 | ) 70 | 71 | func TestCanUnmarshalPreviousConfig(t *testing.T) { 72 | testConf := &configuration{} 73 | if err := json.NewDecoder(bytes.NewReader([]byte(samplePreviousConfJson))).Decode(testConf); err != nil { 74 | t.Fatal(err) 75 | } 76 | if testConf.PasswordConf.Formatter != "something" { 77 | t.Fatal("test failed to unmarshal password conf") 78 | } 79 | if testConf.ADConf.Url != "www.somewhere.com" { 80 | t.Fatal("test failed to unmarshal active directory client conf") 81 | } 82 | } 83 | 84 | func TestCanUnmarshalNewConfig(t *testing.T) { 85 | testConf := &configuration{} 86 | if err := json.NewDecoder(bytes.NewReader([]byte(sampleCurrentConfJson))).Decode(testConf); err != nil { 87 | t.Fatal(err) 88 | } 89 | if testConf.ADConf.LastBindPassword != "foo" { 90 | t.Fatal("test failed to unmarshal bind password information") 91 | } 92 | } 93 | 94 | func TestValidatePasswordConf(t *testing.T) { 95 | type testCase struct { 96 | conf passwordConf 97 | expectErr bool 98 | } 99 | 100 | tests := map[string]testCase{ 101 | "default config errors": { 102 | conf: passwordConf{}, 103 | expectErr: true, 104 | }, 105 | "has policy": { 106 | conf: passwordConf{ 107 | PasswordPolicy: "testpolicy", 108 | }, 109 | expectErr: false, 110 | }, 111 | "has policy name and length": { 112 | conf: passwordConf{ 113 | PasswordPolicy: "testpolicy", 114 | Length: 20, 115 | }, 116 | expectErr: true, 117 | }, 118 | "has policy name and formatter": { 119 | conf: passwordConf{ 120 | PasswordPolicy: "testpolicy", 121 | Formatter: "foo{{PASSWORD}}", 122 | }, 123 | expectErr: true, 124 | }, 125 | "has policy name and length and formatter": { 126 | conf: passwordConf{ 127 | PasswordPolicy: "testpolicy", 128 | Length: 20, 129 | Formatter: "foo{{PASSWORD}}", 130 | }, 131 | expectErr: true, 132 | }, 133 | "no formatter, long length": { 134 | conf: passwordConf{ 135 | Length: minimumLengthOfComplexString + len(passwordComplexityPrefix), 136 | }, 137 | expectErr: false, 138 | }, 139 | "no formatter, too short": { 140 | conf: passwordConf{ 141 | Length: minimumLengthOfComplexString + len(passwordComplexityPrefix) - 1, 142 | }, 143 | expectErr: true, 144 | }, 145 | "has formatter, long length": { 146 | conf: passwordConf{ 147 | Length: minimumLengthOfComplexString + len("foo"), 148 | Formatter: "foo{{PASSWORD}}", 149 | }, 150 | expectErr: false, 151 | }, 152 | "has formatter, short length": { 153 | conf: passwordConf{ 154 | Length: minimumLengthOfComplexString + len("foo") - 1, 155 | Formatter: "foo{{PASSWORD}}", 156 | }, 157 | expectErr: true, 158 | }, 159 | "has formatter, missing PASSWORD field": { 160 | conf: passwordConf{ 161 | Length: 20, 162 | Formatter: "abcde", 163 | }, 164 | expectErr: true, 165 | }, 166 | "has formatter, too many PASSWORD fields": { 167 | conf: passwordConf{ 168 | Length: 50, 169 | Formatter: "foo{{PASSWORD}}{{PASSWORD}}", 170 | }, 171 | expectErr: true, 172 | }, 173 | } 174 | 175 | for name, test := range tests { 176 | t.Run(name, func(t *testing.T) { 177 | err := test.conf.validate() 178 | if test.expectErr && err == nil { 179 | t.Fatalf("err expected, got nil") 180 | } 181 | if !test.expectErr && err != nil { 182 | t.Fatalf("no error expected, got: %s", err) 183 | } 184 | }) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /plugin/client/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "fmt" 8 | "math" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-errors/errors" 13 | "github.com/go-ldap/ldap/v3" 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 16 | "golang.org/x/text/encoding/unicode" 17 | ) 18 | 19 | func NewClient(logger hclog.Logger) *Client { 20 | return &Client{ 21 | ldap: &ldaputil.Client{ 22 | Logger: logger, 23 | LDAP: ldaputil.NewLDAP(), 24 | }, 25 | } 26 | } 27 | 28 | type Client struct { 29 | ldap *ldaputil.Client 30 | } 31 | 32 | func (c *Client) Search(cfg *ADConf, baseDN string, filters map[*Field][]string) ([]*Entry, error) { 33 | req := &ldap.SearchRequest{ 34 | BaseDN: baseDN, 35 | Scope: ldap.ScopeWholeSubtree, 36 | Filter: toString(filters), 37 | SizeLimit: math.MaxInt32, 38 | } 39 | 40 | conn, err := c.ldap.DialLDAP(cfg.ConfigEntry) 41 | if err != nil { 42 | return nil, err 43 | } 44 | defer conn.Close() 45 | 46 | if err := bind(cfg, conn); err != nil { 47 | return nil, err 48 | } 49 | 50 | result, err := conn.Search(req) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | entries := make([]*Entry, len(result.Entries)) 56 | for i, rawEntry := range result.Entries { 57 | entries[i] = NewEntry(rawEntry) 58 | } 59 | return entries, nil 60 | } 61 | 62 | func (c *Client) UpdateEntry(cfg *ADConf, baseDN string, filters map[*Field][]string, newValues map[*Field][]string) error { 63 | entries, err := c.Search(cfg, baseDN, filters) 64 | if err != nil { 65 | return err 66 | } 67 | if len(entries) != 1 { 68 | return fmt.Errorf("filter of %s doesn't match just one entry: %+v", filters, entries) 69 | } 70 | 71 | modifyReq := &ldap.ModifyRequest{ 72 | DN: entries[0].DN, 73 | } 74 | 75 | for field, vals := range newValues { 76 | modifyReq.Replace(field.String(), vals) 77 | } 78 | 79 | conn, err := c.ldap.DialLDAP(cfg.ConfigEntry) 80 | if err != nil { 81 | return err 82 | } 83 | defer conn.Close() 84 | 85 | if err := bind(cfg, conn); err != nil { 86 | return err 87 | } 88 | return conn.Modify(modifyReq) 89 | } 90 | 91 | // UpdatePassword uses a Modify call under the hood because 92 | // Active Directory doesn't recognize the passwordModify method. 93 | // See https://github.com/go-ldap/ldap/issues/106 94 | // for more. 95 | func (c *Client) UpdatePassword(cfg *ADConf, baseDN string, filters map[*Field][]string, newPassword string) error { 96 | pwdEncoded, err := formatPassword(newPassword) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | newValues := map[*Field][]string{ 102 | FieldRegistry.UnicodePassword: {pwdEncoded}, 103 | } 104 | 105 | return c.UpdateEntry(cfg, baseDN, filters, newValues) 106 | } 107 | 108 | // According to the MS docs, the password needs to be utf16 and enclosed in quotes. 109 | func formatPassword(original string) (string, error) { 110 | utf16 := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM) 111 | return utf16.NewEncoder().String("\"" + original + "\"") 112 | } 113 | 114 | // Ex. "(cn=Ellen Jones)" 115 | func toString(filters map[*Field][]string) string { 116 | var fieldEquals []string 117 | for f, values := range filters { 118 | for _, v := range values { 119 | fieldEquals = append(fieldEquals, fmt.Sprintf("%s=%s", f, v)) 120 | } 121 | } 122 | result := strings.Join(fieldEquals, ",") 123 | return "(" + result + ")" 124 | } 125 | 126 | func bind(cfg *ADConf, conn ldaputil.Connection) error { 127 | if cfg.BindPassword == "" { 128 | return errors.New("unable to bind due to lack of configured password") 129 | } 130 | 131 | if cfg.UPNDomain != "" { 132 | origErr := conn.Bind(fmt.Sprintf("%s@%s", ldaputil.EscapeLDAPValue(cfg.BindDN), cfg.UPNDomain), cfg.BindPassword) 133 | if origErr == nil { 134 | return nil 135 | } 136 | if !shouldTryLastPwd(cfg.LastBindPassword, cfg.LastBindPasswordRotation) { 137 | return origErr 138 | } 139 | if err := conn.Bind(fmt.Sprintf("%s@%s", ldaputil.EscapeLDAPValue(cfg.BindDN), cfg.UPNDomain), cfg.LastBindPassword); err != nil { 140 | // Return the original error because it'll be more helpful for debugging. 141 | return origErr 142 | } 143 | return nil 144 | } 145 | 146 | if cfg.BindDN != "" { 147 | origErr := conn.Bind(cfg.BindDN, cfg.BindPassword) 148 | if origErr == nil { 149 | return nil 150 | } 151 | if !shouldTryLastPwd(cfg.LastBindPassword, cfg.LastBindPasswordRotation) { 152 | return origErr 153 | } 154 | if err := conn.Bind(cfg.BindDN, cfg.LastBindPassword); err != nil { 155 | // Return the original error because it'll be more helpful for debugging. 156 | return origErr 157 | } 158 | } 159 | return errors.New("must provide binddn or upndomain") 160 | } 161 | 162 | // shouldTryLastPwd determines if we should try a previous password. 163 | // Active Directory can return a variety of errors when a password is invalid. 164 | // Rather than attempting to catalogue these errors across multiple versions of 165 | // AD, we simply try the last password if it's been less than a set amount of 166 | // time since a rotation occurred. 167 | func shouldTryLastPwd(lastPwd string, lastBindPasswordRotation time.Time) bool { 168 | if lastPwd == "" { 169 | return false 170 | } 171 | if lastBindPasswordRotation.Equal(time.Time{}) { 172 | return false 173 | } 174 | return lastBindPasswordRotation.Add(10 * time.Minute).After(time.Now()) 175 | } 176 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/vault-plugin-secrets-ad 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/go-errors/errors v1.5.1 7 | github.com/go-ldap/ldap/v3 v3.4.12 8 | github.com/hashicorp/go-hclog v1.6.3 9 | github.com/hashicorp/go-metrics v0.5.4 10 | github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 11 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 12 | github.com/hashicorp/vault/api v1.22.0 13 | github.com/hashicorp/vault/sdk v0.20.0 14 | github.com/mitchellh/mapstructure v1.5.0 15 | github.com/patrickmn/go-cache v2.1.0+incompatible 16 | github.com/stretchr/testify v1.11.1 17 | golang.org/x/text v0.29.0 18 | ) 19 | 20 | require ( 21 | cloud.google.com/go/auth v0.14.1 // indirect 22 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 23 | cloud.google.com/go/cloudsqlconn v1.4.3 // indirect 24 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 25 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 26 | github.com/Microsoft/go-winio v0.6.2 // indirect 27 | github.com/armon/go-metrics v0.4.1 // indirect 28 | github.com/armon/go-radix v1.0.0 // indirect 29 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 30 | github.com/containerd/errdefs v1.0.0 // indirect 31 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 33 | github.com/distribution/reference v0.6.0 // indirect 34 | github.com/docker/docker v28.3.3+incompatible // indirect 35 | github.com/docker/go-connections v0.5.0 // indirect 36 | github.com/docker/go-units v0.5.0 // indirect 37 | github.com/evanphx/json-patch/v5 v5.6.0 // indirect 38 | github.com/fatih/color v1.18.0 // indirect 39 | github.com/felixge/httpsnoop v1.0.4 // indirect 40 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect 41 | github.com/go-jose/go-jose/v4 v4.1.1 // indirect 42 | github.com/go-logr/logr v1.4.2 // indirect 43 | github.com/go-logr/stdr v1.2.2 // indirect 44 | github.com/gogo/protobuf v1.3.2 // indirect 45 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 46 | github.com/golang/protobuf v1.5.4 // indirect 47 | github.com/golang/snappy v0.0.4 // indirect 48 | github.com/google/certificate-transparency-go v1.3.1 // indirect 49 | github.com/google/s2a-go v0.1.9 // indirect 50 | github.com/google/uuid v1.6.0 // indirect 51 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 52 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 53 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect 54 | github.com/hashicorp/cap/ldap v0.0.0-20250911140431-44d01434c285 // indirect 55 | github.com/hashicorp/errwrap v1.1.0 // indirect 56 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 57 | github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 // indirect 58 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 59 | github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.1 // indirect 60 | github.com/hashicorp/go-kms-wrapping/v2 v2.0.18 // indirect 61 | github.com/hashicorp/go-multierror v1.1.1 // indirect 62 | github.com/hashicorp/go-plugin v1.6.1 // indirect 63 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 64 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 65 | github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1 // indirect 66 | github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 // indirect 67 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 68 | github.com/hashicorp/go-secure-stdlib/permitpool v1.0.0 // indirect 69 | github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // indirect 70 | github.com/hashicorp/go-secure-stdlib/regexp v1.0.0 // indirect 71 | github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 // indirect 72 | github.com/hashicorp/go-sockaddr v1.0.7 // indirect 73 | github.com/hashicorp/go-uuid v1.0.3 // indirect 74 | github.com/hashicorp/go-version v1.7.0 // indirect 75 | github.com/hashicorp/golang-lru v1.0.2 // indirect 76 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 77 | github.com/hashicorp/yamux v0.1.2 // indirect 78 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 79 | github.com/jackc/pgconn v1.14.3 // indirect 80 | github.com/jackc/pgio v1.0.0 // indirect 81 | github.com/jackc/pgpassfile v1.0.0 // indirect 82 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 83 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 84 | github.com/jackc/pgtype v1.14.3 // indirect 85 | github.com/jackc/pgx/v4 v4.18.3 // indirect 86 | github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect 87 | github.com/mattn/go-colorable v0.1.14 // indirect 88 | github.com/mattn/go-isatty v0.0.20 // indirect 89 | github.com/mitchellh/copystructure v1.2.0 // indirect 90 | github.com/mitchellh/go-homedir v1.1.0 // indirect 91 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 92 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 93 | github.com/moby/docker-image-spec v1.3.1 // indirect 94 | github.com/oklog/run v1.1.0 // indirect 95 | github.com/opencontainers/go-digest v1.0.0 // indirect 96 | github.com/opencontainers/image-spec v1.1.0 // indirect 97 | github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e // indirect 98 | github.com/pierrec/lz4 v2.6.1+incompatible // indirect 99 | github.com/pkg/errors v0.9.1 // indirect 100 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 101 | github.com/robfig/cron/v3 v3.0.1 // indirect 102 | github.com/ryanuber/go-glob v1.0.0 // indirect 103 | github.com/sasha-s/go-deadlock v0.3.5 // indirect 104 | go.opencensus.io v0.24.0 // indirect 105 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 106 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 107 | go.opentelemetry.io/otel v1.35.0 // indirect 108 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect 109 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 110 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 111 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 112 | go.uber.org/atomic v1.11.0 // indirect 113 | golang.org/x/crypto v0.40.0 // indirect 114 | golang.org/x/net v0.42.0 // indirect 115 | golang.org/x/oauth2 v0.28.0 // indirect 116 | golang.org/x/sys v0.34.0 // indirect 117 | golang.org/x/time v0.12.0 // indirect 118 | google.golang.org/api v0.221.0 // indirect 119 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect 120 | google.golang.org/grpc v1.70.0 // indirect 121 | google.golang.org/protobuf v1.36.5 // indirect 122 | gopkg.in/yaml.v3 v3.0.1 // indirect 123 | ) 124 | -------------------------------------------------------------------------------- /plugin/checkout_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/hashicorp/vault/sdk/logical" 11 | ) 12 | 13 | const ( 14 | checkoutStoragePrefix = "checkout/" 15 | passwordStoragePrefix = "password/" 16 | ) 17 | 18 | var ( 19 | // errCheckedOut is returned when a check-out request is received 20 | // for a service account that's already checked out. 21 | errCheckedOut = errors.New("checked out") 22 | 23 | // errNotFound is used when a requested item doesn't exist. 24 | errNotFound = errors.New("not found") 25 | ) 26 | 27 | // CheckOut provides information for a service account that is currently 28 | // checked out. 29 | type CheckOut struct { 30 | IsAvailable bool `json:"is_available"` 31 | BorrowerEntityID string `json:"borrower_entity_id"` 32 | BorrowerClientToken string `json:"borrower_client_token"` 33 | } 34 | 35 | // checkOutHandler manages checkouts. It's not thread-safe and expects the caller to handle locking because 36 | // locking may span multiple calls. 37 | type checkOutHandler struct { 38 | client secretsClient 39 | passwordGenerator passwordGenerator 40 | } 41 | 42 | // CheckOut attempts to check out a service account. If the account is unavailable, it returns 43 | // errCheckedOut. If the service account isn't managed by this plugin, it returns 44 | // errNotFound. 45 | func (h *checkOutHandler) CheckOut(ctx context.Context, storage logical.Storage, serviceAccountName string, checkOut *CheckOut) error { 46 | if ctx == nil { 47 | return errors.New("ctx must be provided") 48 | } 49 | if storage == nil { 50 | return errors.New("storage must be provided") 51 | } 52 | if serviceAccountName == "" { 53 | return errors.New("service account name must be provided") 54 | } 55 | if checkOut == nil { 56 | return errors.New("check-out must be provided") 57 | } 58 | 59 | // Check if the service account is currently checked out. 60 | currentEntry, err := storage.Get(ctx, checkoutStoragePrefix+serviceAccountName) 61 | if err != nil { 62 | return err 63 | } 64 | if currentEntry == nil { 65 | return errNotFound 66 | } 67 | currentCheckOut := &CheckOut{} 68 | if err := currentEntry.DecodeJSON(currentCheckOut); err != nil { 69 | return err 70 | } 71 | if !currentCheckOut.IsAvailable { 72 | return errCheckedOut 73 | } 74 | 75 | // Since it's not, store the new check-out. 76 | entry, err := logical.StorageEntryJSON(checkoutStoragePrefix+serviceAccountName, checkOut) 77 | if err != nil { 78 | return err 79 | } 80 | return storage.Put(ctx, entry) 81 | } 82 | 83 | // CheckIn attempts to check in a service account. If an error occurs, the account remains checked out 84 | // and can either be retried by the caller, or eventually may be checked in if it has a ttl 85 | // that ends. 86 | func (h *checkOutHandler) CheckIn(ctx context.Context, storage logical.Storage, serviceAccountName string) error { 87 | if ctx == nil { 88 | return errors.New("ctx must be provided") 89 | } 90 | if storage == nil { 91 | return errors.New("storage must be provided") 92 | } 93 | if serviceAccountName == "" { 94 | return errors.New("service account name must be provided") 95 | } 96 | 97 | // On check-ins, a new AD password is generated, updated in AD, and stored. 98 | engineConf, err := readConfig(ctx, storage) 99 | if err != nil { 100 | return err 101 | } 102 | if engineConf == nil { 103 | return errors.New("the config is currently unset") 104 | } 105 | newPassword, err := GeneratePassword(ctx, engineConf.PasswordConf, h.passwordGenerator) 106 | if err != nil { 107 | return err 108 | } 109 | if err := h.client.UpdatePassword(engineConf.ADConf, serviceAccountName, newPassword); err != nil { 110 | return err 111 | } 112 | pwdEntry, err := logical.StorageEntryJSON(passwordStoragePrefix+serviceAccountName, newPassword) 113 | if err != nil { 114 | return err 115 | } 116 | if err := storage.Put(ctx, pwdEntry); err != nil { 117 | return err 118 | } 119 | 120 | // That ends the password-handling leg of our journey, now let's deal with the stored check-out itself. 121 | // Store a check-out status indicating it's available. 122 | checkOut := &CheckOut{ 123 | IsAvailable: true, 124 | } 125 | entry, err := logical.StorageEntryJSON(checkoutStoragePrefix+serviceAccountName, checkOut) 126 | if err != nil { 127 | return err 128 | } 129 | return storage.Put(ctx, entry) 130 | } 131 | 132 | // LoadCheckOut returns either: 133 | // - A *CheckOut and nil error if the serviceAccountName is currently managed by this engine. 134 | // - A nil *Checkout and errNotFound if the serviceAccountName is not currently managed by this engine. 135 | func (h *checkOutHandler) LoadCheckOut(ctx context.Context, storage logical.Storage, serviceAccountName string) (*CheckOut, error) { 136 | if ctx == nil { 137 | return nil, errors.New("ctx must be provided") 138 | } 139 | if storage == nil { 140 | return nil, errors.New("storage must be provided") 141 | } 142 | if serviceAccountName == "" { 143 | return nil, errors.New("service account name must be provided") 144 | } 145 | 146 | entry, err := storage.Get(ctx, checkoutStoragePrefix+serviceAccountName) 147 | if err != nil { 148 | return nil, err 149 | } 150 | if entry == nil { 151 | return nil, errNotFound 152 | } 153 | checkOut := &CheckOut{} 154 | if err := entry.DecodeJSON(checkOut); err != nil { 155 | return nil, err 156 | } 157 | return checkOut, nil 158 | } 159 | 160 | // Delete cleans up anything we were tracking from the service account that we will no longer need. 161 | func (h *checkOutHandler) Delete(ctx context.Context, storage logical.Storage, serviceAccountName string) error { 162 | if ctx == nil { 163 | return errors.New("ctx must be provided") 164 | } 165 | if storage == nil { 166 | return errors.New("storage must be provided") 167 | } 168 | if serviceAccountName == "" { 169 | return errors.New("service account name must be provided") 170 | } 171 | 172 | if err := storage.Delete(ctx, passwordStoragePrefix+serviceAccountName); err != nil { 173 | return err 174 | } 175 | return storage.Delete(ctx, checkoutStoragePrefix+serviceAccountName) 176 | } 177 | 178 | // retrievePassword is a utility function for grabbing a service account's password from storage. 179 | // retrievePassword will return: 180 | // - "password", nil if it was successfully able to retrieve the password. 181 | // - errNotFound if there's no password presently. 182 | // - Some other err if it was unable to complete successfully. 183 | func retrievePassword(ctx context.Context, storage logical.Storage, serviceAccountName string) (string, error) { 184 | entry, err := storage.Get(ctx, passwordStoragePrefix+serviceAccountName) 185 | if err != nil { 186 | return "", err 187 | } 188 | if entry == nil { 189 | return "", errNotFound 190 | } 191 | password := "" 192 | if err := entry.DecodeJSON(&password); err != nil { 193 | return "", err 194 | } 195 | return password, nil 196 | } 197 | -------------------------------------------------------------------------------- /plugin/client/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package client 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/go-ldap/ldap/v3" 10 | "github.com/hashicorp/go-hclog" 11 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 12 | 13 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/ldapifc" 14 | ) 15 | 16 | func TestSearch(t *testing.T) { 17 | config := emptyConfig() 18 | 19 | conn := &ldapifc.FakeLDAPConnection{ 20 | SearchRequestToExpect: testSearchRequest(), 21 | SearchResultToReturn: testSearchResult(), 22 | } 23 | 24 | ldapClient := &ldaputil.Client{ 25 | Logger: hclog.NewNullLogger(), 26 | LDAP: &ldapifc.FakeLDAPClient{ 27 | ConnToReturn: conn, 28 | }, 29 | } 30 | 31 | client := &Client{ldap: ldapClient} 32 | 33 | filters := map[*Field][]string{ 34 | FieldRegistry.Surname: {"Jones"}, 35 | } 36 | 37 | entries, err := client.Search(config, config.UserDN, filters) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | if len(entries) != 1 { 43 | t.Fatalf("only one entry was provided, but multiple were found: %+v", entries) 44 | } 45 | entry := entries[0] 46 | 47 | result, _ := entry.GetJoined(FieldRegistry.Surname) 48 | if result != "Jones" { 49 | t.Fatalf("expected Surname of \"Jones\" but received %q", result) 50 | } 51 | 52 | result, _ = entry.GetJoined(FieldRegistry.BadPasswordTime) 53 | if result != "131653637947737037" { 54 | t.Fatalf("expected BadPasswordTime of \"131653637947737037\" but received %q", result) 55 | } 56 | 57 | result, _ = entry.GetJoined(FieldRegistry.PasswordLastSet) 58 | if result != "0" { 59 | t.Fatalf("expected PasswordLastSet of \"0\" but received %q", result) 60 | } 61 | 62 | result, _ = entry.GetJoined(FieldRegistry.PrimaryGroupID) 63 | if result != "513" { 64 | t.Fatalf("expected PrimaryGroupID of \"513\" but received %q", result) 65 | } 66 | 67 | result, _ = entry.GetJoined(FieldRegistry.UserPrincipalName) 68 | if result != "jim@example.com" { 69 | t.Fatalf("expected UserPrincipalName of \"jim@example.com\" but received %q", result) 70 | } 71 | 72 | result, _ = entry.GetJoined(FieldRegistry.ObjectClass) 73 | if result != "top,person,organizationalPerson,user" { 74 | t.Fatalf("expected ObjectClass of \"top,person,organizationalPerson,user\" but received %q", result) 75 | } 76 | } 77 | 78 | func TestUpdateEntry(t *testing.T) { 79 | config := emptyConfig() 80 | 81 | conn := &ldapifc.FakeLDAPConnection{ 82 | SearchRequestToExpect: testSearchRequest(), 83 | SearchResultToReturn: testSearchResult(), 84 | } 85 | 86 | conn.ModifyRequestToExpect = &ldap.ModifyRequest{ 87 | DN: "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com", 88 | } 89 | conn.ModifyRequestToExpect.Replace("cn", []string{"Blue", "Red"}) 90 | ldapClient := &ldaputil.Client{ 91 | Logger: hclog.NewNullLogger(), 92 | LDAP: &ldapifc.FakeLDAPClient{conn}, 93 | } 94 | 95 | client := &Client{ldapClient} 96 | 97 | filters := map[*Field][]string{ 98 | FieldRegistry.Surname: {"Jones"}, 99 | } 100 | 101 | newValues := map[*Field][]string{ 102 | FieldRegistry.CommonName: {"Blue", "Red"}, 103 | } 104 | 105 | if err := client.UpdateEntry(config, config.UserDN, filters, newValues); err != nil { 106 | t.Fatal(err) 107 | } 108 | } 109 | 110 | func TestUpdatePassword(t *testing.T) { 111 | testPass := "hell0$catz*" 112 | 113 | config := emptyConfig() 114 | config.BindDN = "cats" 115 | config.BindPassword = "dogs" 116 | 117 | conn := &ldapifc.FakeLDAPConnection{ 118 | SearchRequestToExpect: testSearchRequest(), 119 | SearchResultToReturn: testSearchResult(), 120 | } 121 | 122 | expectedPass, err := formatPassword(testPass) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | conn.ModifyRequestToExpect = &ldap.ModifyRequest{ 127 | DN: "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com", 128 | } 129 | conn.ModifyRequestToExpect.Replace("unicodePwd", []string{expectedPass}) 130 | ldapClient := &ldaputil.Client{ 131 | Logger: hclog.NewNullLogger(), 132 | LDAP: &ldapifc.FakeLDAPClient{conn}, 133 | } 134 | 135 | client := &Client{ldapClient} 136 | 137 | filters := map[*Field][]string{ 138 | FieldRegistry.Surname: {"Jones"}, 139 | } 140 | 141 | if err := client.UpdatePassword(config, config.UserDN, filters, testPass); err != nil { 142 | t.Fatal(err) 143 | } 144 | } 145 | 146 | // TestUpdateRootPassword mimics the UpdateRootPassword in the SecretsClient. 147 | // However, this test must be located within this package because when the 148 | // "client" is instantiated below, the "ldapClient" is being added to an 149 | // unexported field. 150 | func TestUpdateRootPassword(t *testing.T) { 151 | testPass := "hell0$catz*" 152 | 153 | config := emptyConfig() 154 | config.BindDN = "cats" 155 | config.BindPassword = "dogs" 156 | 157 | expectedRequest := testSearchRequest() 158 | expectedRequest.BaseDN = config.BindDN 159 | conn := &ldapifc.FakeLDAPConnection{ 160 | SearchRequestToExpect: expectedRequest, 161 | SearchResultToReturn: testSearchResult(), 162 | } 163 | 164 | expectedPass, err := formatPassword(testPass) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | conn.ModifyRequestToExpect = &ldap.ModifyRequest{ 169 | DN: "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com", 170 | } 171 | conn.ModifyRequestToExpect.Replace("unicodePwd", []string{expectedPass}) 172 | ldapClient := &ldaputil.Client{ 173 | Logger: hclog.NewNullLogger(), 174 | LDAP: &ldapifc.FakeLDAPClient{conn}, 175 | } 176 | 177 | client := &Client{ldapClient} 178 | 179 | filters := map[*Field][]string{ 180 | FieldRegistry.Surname: {"Jones"}, 181 | } 182 | 183 | if err := client.UpdatePassword(config, config.BindDN, filters, testPass); err != nil { 184 | t.Fatal(err) 185 | } 186 | } 187 | 188 | func emptyConfig() *ADConf { 189 | return &ADConf{ 190 | ConfigEntry: &ldaputil.ConfigEntry{ 191 | UserDN: "dc=example,dc=com", 192 | Url: "ldap://127.0.0.1", 193 | BindDN: "cats", 194 | BindPassword: "cats", 195 | }, 196 | } 197 | } 198 | 199 | func testSearchRequest() *ldap.SearchRequest { 200 | return &ldap.SearchRequest{ 201 | BaseDN: "dc=example,dc=com", 202 | Scope: ldap.ScopeWholeSubtree, 203 | Filter: "(sn=Jones)", 204 | } 205 | } 206 | 207 | func testSearchResult() *ldap.SearchResult { 208 | return &ldap.SearchResult{ 209 | Entries: []*ldap.Entry{ 210 | { 211 | DN: "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com", 212 | Attributes: []*ldap.EntryAttribute{ 213 | { 214 | Name: FieldRegistry.Surname.String(), 215 | Values: []string{"Jones"}, 216 | }, 217 | { 218 | Name: FieldRegistry.BadPasswordTime.String(), 219 | Values: []string{"131653637947737037"}, 220 | }, 221 | { 222 | Name: FieldRegistry.PasswordLastSet.String(), 223 | Values: []string{"0"}, 224 | }, 225 | { 226 | Name: FieldRegistry.PrimaryGroupID.String(), 227 | Values: []string{"513"}, 228 | }, 229 | { 230 | Name: FieldRegistry.UserPrincipalName.String(), 231 | Values: []string{"jim@example.com"}, 232 | }, 233 | { 234 | Name: FieldRegistry.ObjectClass.String(), 235 | Values: []string{"top", "person", "organizationalPerson", "user"}, 236 | }, 237 | }, 238 | }, 239 | }, 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /plugin/path_roles.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-errors/errors" 13 | "github.com/hashicorp/vault/sdk/framework" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | ) 16 | 17 | const ( 18 | rolePath = "roles" 19 | rolePrefix = "roles/" 20 | roleStorageKey = "roles" 21 | 22 | roleCacheCleanup = time.Second / 2 23 | roleCacheExpiration = time.Second 24 | ) 25 | 26 | func (b *backend) invalidateRole(ctx context.Context, key string) { 27 | if strings.HasPrefix(key, rolePrefix) { 28 | roleName := key[len(rolePrefix):] 29 | b.roleCache.Delete(roleName) 30 | } 31 | } 32 | 33 | func (b *backend) pathListRoles() *framework.Path { 34 | return &framework.Path{ 35 | Pattern: rolePrefix + "?$", 36 | 37 | Callbacks: map[logical.Operation]framework.OperationFunc{ 38 | logical.ListOperation: b.roleListOperation, 39 | }, 40 | 41 | HelpSynopsis: pathListRolesHelpSyn, 42 | HelpDescription: pathListRolesHelpDesc, 43 | } 44 | } 45 | 46 | func (b *backend) pathRoles() *framework.Path { 47 | return &framework.Path{ 48 | Pattern: rolePrefix + framework.GenericNameRegex("name"), 49 | Fields: map[string]*framework.FieldSchema{ 50 | "name": { 51 | Type: framework.TypeLowerCaseString, 52 | Description: "Name of the role", 53 | }, 54 | "service_account_name": { 55 | Type: framework.TypeString, 56 | Description: "The username/logon name for the service account with which this role will be associated.", 57 | }, 58 | "ttl": { 59 | Type: framework.TypeDurationSecond, 60 | Description: "In seconds, the default password time-to-live.", 61 | }, 62 | }, 63 | Callbacks: map[logical.Operation]framework.OperationFunc{ 64 | logical.UpdateOperation: b.roleUpdateOperation, 65 | logical.ReadOperation: b.roleReadOperation, 66 | logical.DeleteOperation: b.roleDeleteOperation, 67 | }, 68 | HelpSynopsis: roleHelpSynopsis, 69 | HelpDescription: roleHelpDescription, 70 | } 71 | } 72 | 73 | func (b *backend) readRole(ctx context.Context, storage logical.Storage, roleName string) (*backendRole, error) { 74 | // If it's cached, return it from there. 75 | roleIfc, found := b.roleCache.Get(roleName) 76 | if found { 77 | return roleIfc.(*backendRole), nil 78 | } 79 | 80 | // It's not, read it from storage. 81 | entry, err := storage.Get(ctx, roleStorageKey+"/"+roleName) 82 | if err != nil { 83 | return nil, err 84 | } 85 | if entry == nil { 86 | return nil, nil 87 | } 88 | 89 | role := &backendRole{} 90 | if err := entry.DecodeJSON(role); err != nil { 91 | return nil, err 92 | } 93 | 94 | // Always check when ActiveDirectory shows the password as last set on the fly. 95 | engineConf, err := readConfig(ctx, storage) 96 | if err != nil { 97 | return nil, err 98 | } 99 | if engineConf == nil { 100 | return nil, errors.New("the config is currently unset") 101 | } 102 | 103 | passwordLastSet, err := b.client.GetPasswordLastSet(engineConf.ADConf, role.ServiceAccountName) 104 | if err != nil { 105 | return nil, err 106 | } 107 | role.PasswordLastSet = passwordLastSet 108 | 109 | // Cache it. 110 | b.roleCache.SetDefault(roleName, role) 111 | return role, nil 112 | } 113 | 114 | func (b *backend) writeRoleToStorage(ctx context.Context, storage logical.Storage, roleName string, role *backendRole) error { 115 | entry, err := logical.StorageEntryJSON(roleStorageKey+"/"+roleName, role) 116 | if err != nil { 117 | return err 118 | } 119 | if err := storage.Put(ctx, entry); err != nil { 120 | return err 121 | } 122 | // Invalidate the cache. 123 | b.roleCache.Delete(roleName) 124 | return nil 125 | } 126 | 127 | func (b *backend) roleUpdateOperation(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 128 | // Get everything we need to construct the role. 129 | roleName := fieldData.Get("name").(string) 130 | 131 | engineConf, err := readConfig(ctx, req.Storage) 132 | if err != nil { 133 | return nil, err 134 | } 135 | if engineConf == nil { 136 | return nil, errors.New("the config is currently unset") 137 | } 138 | 139 | // Actually construct it. 140 | serviceAccountName, err := getServiceAccountName(fieldData) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | // verify service account exists 146 | _, err = b.client.Get(engineConf.ADConf, serviceAccountName) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | ttl, err := getValidatedTTL(engineConf.PasswordConf, fieldData) 152 | if err != nil { 153 | return nil, err 154 | } 155 | role := &backendRole{ 156 | ServiceAccountName: serviceAccountName, 157 | TTL: ttl, 158 | } 159 | 160 | // Was there already a role before that we're now overwriting? If so, let's carry forward the LastVaultRotation. 161 | oldRole, err := b.readRole(ctx, req.Storage, roleName) 162 | if err != nil { 163 | return nil, err 164 | } else { 165 | if oldRole != nil { 166 | role.LastVaultRotation = oldRole.LastVaultRotation 167 | } 168 | } 169 | 170 | // writeRoleToStorage it to storage, but not to the role cache because its 171 | // last updated time from AD is only grabbed on reads. 172 | if err := b.writeRoleToStorage(ctx, req.Storage, roleName, role); err != nil { 173 | return nil, err 174 | } 175 | 176 | // Return a 204. 177 | return nil, nil 178 | } 179 | 180 | func (b *backend) roleReadOperation(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 181 | roleName := fieldData.Get("name").(string) 182 | 183 | role, err := b.readRole(ctx, req.Storage, roleName) 184 | if err != nil { 185 | return nil, err 186 | } 187 | if role == nil { 188 | return nil, nil 189 | } 190 | 191 | return &logical.Response{ 192 | Data: role.Map(), 193 | }, nil 194 | } 195 | 196 | func (b *backend) roleListOperation(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { 197 | keys, err := req.Storage.List(ctx, roleStorageKey+"/") 198 | if err != nil { 199 | return nil, err 200 | } 201 | return logical.ListResponse(keys), nil 202 | } 203 | 204 | func (b *backend) roleDeleteOperation(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 205 | roleName := fieldData.Get("name").(string) 206 | 207 | if err := req.Storage.Delete(ctx, roleStorageKey+"/"+roleName); err != nil { 208 | return nil, err 209 | } 210 | 211 | b.roleCache.Delete(roleName) 212 | 213 | if err := b.deleteCred(ctx, req.Storage, roleName); err != nil { 214 | return nil, err 215 | } 216 | return nil, nil 217 | } 218 | 219 | func getServiceAccountName(fieldData *framework.FieldData) (string, error) { 220 | serviceAccountName := fieldData.Get("service_account_name").(string) 221 | if serviceAccountName == "" { 222 | return "", errors.New("\"service_account_name\" is required") 223 | } 224 | return serviceAccountName, nil 225 | } 226 | 227 | func getValidatedTTL(passwordConf passwordConf, fieldData *framework.FieldData) (int, error) { 228 | ttl := fieldData.Get("ttl").(int) 229 | if ttl == 0 { 230 | ttl = passwordConf.TTL 231 | } 232 | if ttl > passwordConf.MaxTTL { 233 | return 0, fmt.Errorf("requested ttl of %d seconds is over the max ttl of %d seconds", ttl, passwordConf.MaxTTL) 234 | } 235 | if ttl < 0 { 236 | return 0, fmt.Errorf("ttl can't be negative") 237 | } 238 | return ttl, nil 239 | } 240 | 241 | const ( 242 | roleHelpSynopsis = ` 243 | Manage roles to build links between Vault and Active Directory service accounts. 244 | ` 245 | roleHelpDescription = ` 246 | This endpoint allows you to read, write, and delete individual roles that are used for enabling password rotation. 247 | 248 | Deleting a role will not disable its current password. It will delete the role's associated creds in Vault. 249 | ` 250 | 251 | pathListRolesHelpSyn = ` 252 | List the name of each role currently stored. 253 | ` 254 | pathListRolesHelpDesc = ` 255 | To learn which service accounts are being managed by Vault, list the role names using 256 | this endpoint. Then read any individual role by name to learn more, like the name of 257 | the service account it's associated with. 258 | ` 259 | ) 260 | -------------------------------------------------------------------------------- /plugin/path_creds.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-errors/errors" 13 | "github.com/hashicorp/vault/sdk/framework" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | ) 16 | 17 | const ( 18 | credPrefix = "creds/" 19 | storageKey = "creds" 20 | 21 | // Since password TTL can be set to as low as 1 second, 22 | // we can't cache passwords for an entire second. 23 | credCacheCleanup = time.Second / 3 24 | credCacheExpiration = time.Second / 2 25 | ) 26 | 27 | // deleteCred fulfills the DeleteWatcher interface in roles. 28 | // It allows the roleHandler to let us know when a role's been deleted so we can delete its associated creds too. 29 | func (b *backend) deleteCred(ctx context.Context, storage logical.Storage, roleName string) error { 30 | if err := storage.Delete(ctx, storageKey+"/"+roleName); err != nil { 31 | return err 32 | } 33 | b.credCache.Delete(roleName) 34 | return nil 35 | } 36 | 37 | func (b *backend) invalidateCred(ctx context.Context, key string) { 38 | if strings.HasPrefix(key, credPrefix) { 39 | roleName := key[len(credPrefix):] 40 | b.credCache.Delete(roleName) 41 | } 42 | } 43 | 44 | func (b *backend) pathCreds() *framework.Path { 45 | return &framework.Path{ 46 | Pattern: credPrefix + framework.GenericNameRegex("name"), 47 | Fields: map[string]*framework.FieldSchema{ 48 | "name": { 49 | Type: framework.TypeString, 50 | Description: "Name of the role", 51 | }, 52 | }, 53 | Operations: map[logical.Operation]framework.OperationHandler{ 54 | logical.ReadOperation: &framework.PathOperation{ 55 | Callback: b.credReadOperation, 56 | ForwardPerformanceStandby: true, 57 | ForwardPerformanceSecondary: true, 58 | }, 59 | }, 60 | HelpSynopsis: credHelpSynopsis, 61 | HelpDescription: credHelpDescription, 62 | } 63 | } 64 | 65 | func (b *backend) credReadOperation(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 66 | cred := make(map[string]interface{}) 67 | 68 | engineConf, err := readConfig(ctx, req.Storage) 69 | if err != nil { 70 | return nil, err 71 | } 72 | if engineConf == nil { 73 | return nil, errors.New("the config is currently unset") 74 | } 75 | 76 | roleName := fieldData.Get("name").(string) 77 | 78 | // We act upon quite a few things below that could be racy if not locked: 79 | // - Roles. If a new cred is created, the role is updated to include the new LastVaultRotation time, 80 | // effecting role storage (and the role cache, but that's already thread-safe). 81 | // - Creds. New creds involve writing to cred storage and the cred cache (also already thread-safe). 82 | // Rather than setting read locks of different types, and upgrading them to write locks, let's keep complexity 83 | // low and use one simple mutex. 84 | b.credLock.Lock() 85 | defer b.credLock.Unlock() 86 | 87 | role, err := b.readRole(ctx, req.Storage, roleName) 88 | if err != nil { 89 | return nil, err 90 | } 91 | if role == nil { 92 | return nil, nil 93 | } 94 | b.Logger().Debug(fmt.Sprintf("role is: %+v", role)) 95 | 96 | var resp *logical.Response 97 | var respErr error 98 | var unset time.Time 99 | 100 | switch { 101 | 102 | case role.LastVaultRotation == unset: 103 | b.Logger().Info("rotating password for the first time so Vault will know it") 104 | resp, respErr = b.generateAndReturnCreds(ctx, engineConf, req.Storage, roleName, role, cred) 105 | 106 | case role.PasswordLastSet.After(role.LastVaultRotation.Add(time.Second * time.Duration(engineConf.LastRotationTolerance))): 107 | b.Logger().Warn(fmt.Sprintf( 108 | "Vault rotated the password at %s, but it was rotated in AD later at %s, so rotating it again so Vault will know it", 109 | role.LastVaultRotation.String(), role.PasswordLastSet.String()), 110 | ) 111 | resp, respErr = b.generateAndReturnCreds(ctx, engineConf, req.Storage, roleName, role, cred) 112 | 113 | default: 114 | b.Logger().Debug("determining whether to rotate credential") 115 | credIfc, found := b.credCache.Get(roleName) 116 | if found { 117 | b.Logger().Debug("checking cached credential") 118 | cred = credIfc.(map[string]interface{}) 119 | } else { 120 | b.Logger().Debug("checking stored credential") 121 | entry, err := req.Storage.Get(ctx, storageKey+"/"+roleName) 122 | if err != nil { 123 | return nil, err 124 | } 125 | if entry == nil { 126 | // If the creds aren't in storage, but roles are and we've created creds before, 127 | // this is an unexpected state and something has gone wrong. 128 | // Let's be explicit and error about this. 129 | return nil, fmt.Errorf("should have the creds for %+v but they're not found", role) 130 | } 131 | if err := entry.DecodeJSON(&cred); err != nil { 132 | return nil, err 133 | } 134 | b.credCache.SetDefault(roleName, cred) 135 | } 136 | 137 | now := time.Now().UTC() 138 | shouldBeRolled := role.LastVaultRotation.Add(time.Duration(role.TTL) * time.Second) // already in UTC 139 | if now.After(shouldBeRolled) { 140 | b.Logger().Info(fmt.Sprintf( 141 | "last Vault rotation was at %s, and since the TTL is %d and it's now %s, it's time to rotate it", 142 | role.LastVaultRotation.String(), role.TTL, now.String()), 143 | ) 144 | resp, respErr = b.generateAndReturnCreds(ctx, engineConf, req.Storage, roleName, role, cred) 145 | } else { 146 | b.Logger().Debug("returning previous credential") 147 | resp = &logical.Response{ 148 | Data: cred, 149 | } 150 | } 151 | } 152 | if respErr != nil { 153 | return nil, respErr 154 | } 155 | return resp, nil 156 | } 157 | 158 | func (b *backend) generateAndReturnCreds(ctx context.Context, engineConf *configuration, storage logical.Storage, roleName string, role *backendRole, previousCred map[string]interface{}) (*logical.Response, error) { 159 | newPassword, err := GeneratePassword(ctx, engineConf.PasswordConf, b.System()) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | var currentPassword, lastPassword string 165 | if previousCred != nil { 166 | if val, ok := previousCred["current_password"].(string); ok { 167 | currentPassword = val 168 | } 169 | 170 | if val, ok := previousCred["last_password"].(string); ok { 171 | lastPassword = val 172 | } 173 | } 174 | 175 | wal := rotateCredentialEntry{ 176 | CurrentPassword: currentPassword, 177 | LastPassword: lastPassword, 178 | RoleName: roleName, 179 | TTL: role.TTL, 180 | ServiceAccountName: role.ServiceAccountName, 181 | LastVaultRotation: role.LastVaultRotation, 182 | } 183 | 184 | // Bail if we can't persist the WAL 185 | walID, err := framework.PutWAL(ctx, storage, rotateCredentialWAL, wal) 186 | if err != nil { 187 | return nil, fmt.Errorf("could not persist WAL before rotation: %s", err) 188 | } 189 | 190 | err = b.client.UpdatePassword(engineConf.ADConf, role.ServiceAccountName, newPassword) 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | // Time recorded is in UTC for easier user comparison to AD's last rotated time, which is set to UTC by Microsoft. 196 | role.LastVaultRotation = time.Now().UTC() 197 | if err := b.writeRoleToStorage(ctx, storage, roleName, role); err != nil { 198 | return nil, err 199 | } 200 | // Cache the full role to minimize Vault storage calls. 201 | b.roleCache.SetDefault(roleName, role) 202 | 203 | // Although a service account name is typically my_app@example.com, 204 | // the username it uses is just my_app, or everything before the @. 205 | var username string 206 | if username, err = getUsername(role.ServiceAccountName); err != nil { 207 | return nil, err 208 | } 209 | 210 | cred := map[string]interface{}{ 211 | "username": username, 212 | "current_password": newPassword, 213 | } 214 | 215 | if previousCred != nil && previousCred["current_password"] != nil { 216 | cred["last_password"] = previousCred["current_password"] 217 | } 218 | 219 | // Cache and save the cred. 220 | path := fmt.Sprintf("%s/%s", storageKey, roleName) 221 | entry, err := logical.StorageEntryJSON(path, cred) 222 | if err != nil { 223 | return nil, err 224 | } 225 | if err := storage.Put(ctx, entry); err != nil { 226 | return nil, err 227 | } 228 | b.credCache.SetDefault(roleName, cred) 229 | 230 | // Delete the WAL entry 231 | if err := framework.DeleteWAL(ctx, storage, walID); err != nil { 232 | // The rotation was successful, so don't return the error. 233 | // The WAL will eventually be discarded by the rollback handler. 234 | b.Logger().Warn("failed to delete password rotation WAL", "error", err.Error()) 235 | } 236 | 237 | return &logical.Response{ 238 | Data: cred, 239 | }, nil 240 | } 241 | 242 | // getUsername extracts the username from a service account name by 243 | // splitting on @. For example, if vault@hashicorp.com is the service 244 | // account, vault is the username. 245 | func getUsername(serviceAccount string) (string, error) { 246 | fields := strings.Split(serviceAccount, "@") 247 | if len(fields) > 0 { 248 | return fields[0], nil 249 | } 250 | return "", fmt.Errorf("unable to infer username from service account name: %s", serviceAccount) 251 | } 252 | 253 | const ( 254 | credHelpSynopsis = ` 255 | Retrieve a role's creds by role name. 256 | ` 257 | credHelpDescription = ` 258 | Read creds using a role's name to view the login, current password, and last password. 259 | ` 260 | ) 261 | -------------------------------------------------------------------------------- /plugin/path_config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/hashicorp/vault/sdk/framework" 13 | "github.com/hashicorp/vault/sdk/helper/ldaputil" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | 16 | "github.com/hashicorp/vault-plugin-secrets-ad/plugin/client" 17 | ) 18 | 19 | const ( 20 | configPath = "config" 21 | configStorageKey = "config" 22 | 23 | // This length is arbitrarily chosen but should work for 24 | // most Active Directory minimum and maximum length settings. 25 | // A bit tongue-in-cheek since programmers love their base-2 exponents. 26 | defaultPasswordLength = 64 27 | 28 | defaultTLSVersion = "tls12" 29 | ) 30 | 31 | func readConfig(ctx context.Context, storage logical.Storage) (*configuration, error) { 32 | entry, err := storage.Get(ctx, configStorageKey) 33 | if err != nil { 34 | return nil, err 35 | } 36 | if entry == nil { 37 | return nil, nil 38 | } 39 | config := &configuration{} 40 | if err := entry.DecodeJSON(config); err != nil { 41 | return nil, err 42 | } 43 | return config, nil 44 | } 45 | 46 | func writeConfig(ctx context.Context, storage logical.Storage, config *configuration) (err error) { 47 | entry, err := logical.StorageEntryJSON(configStorageKey, config) 48 | if err != nil { 49 | return fmt.Errorf("unable to marshal config to JSON: %w", err) 50 | } 51 | if err := storage.Put(ctx, entry); err != nil { 52 | return fmt.Errorf("unable to store config: %w", err) 53 | } 54 | return nil 55 | } 56 | 57 | func (b *backend) pathConfig() *framework.Path { 58 | return &framework.Path{ 59 | Pattern: configPath, 60 | Fields: b.configFields(), 61 | Callbacks: map[logical.Operation]framework.OperationFunc{ 62 | logical.UpdateOperation: b.configUpdateOperation, 63 | logical.ReadOperation: b.configReadOperation, 64 | logical.DeleteOperation: b.configDeleteOperation, 65 | }, 66 | HelpSynopsis: configHelpSynopsis, 67 | HelpDescription: configHelpDescription, 68 | } 69 | } 70 | 71 | func (b *backend) configFields() map[string]*framework.FieldSchema { 72 | fields := ldaputil.ConfigFields() 73 | fields["ttl"] = &framework.FieldSchema{ 74 | Type: framework.TypeDurationSecond, 75 | Description: "In seconds, the default password time-to-live.", 76 | } 77 | fields["max_ttl"] = &framework.FieldSchema{ 78 | Type: framework.TypeDurationSecond, 79 | Description: "In seconds, the maximum password time-to-live.", 80 | } 81 | fields["last_rotation_tolerance"] = &framework.FieldSchema{ 82 | Type: framework.TypeDurationSecond, 83 | Description: "The number of seconds after a Vault rotation where, if Active Directory shows a later rotation, it should be considered out-of-band.", 84 | Default: 5, 85 | } 86 | fields["password_policy"] = &framework.FieldSchema{ 87 | Type: framework.TypeString, 88 | Description: "Name of the password policy to use to generate passwords.", 89 | } 90 | 91 | // Deprecated fields 92 | fields["length"] = &framework.FieldSchema{ 93 | Type: framework.TypeInt, 94 | Default: defaultPasswordLength, 95 | Description: "The desired length of passwords that Vault generates.", 96 | Deprecated: true, 97 | } 98 | fields["formatter"] = &framework.FieldSchema{ 99 | Type: framework.TypeString, 100 | Description: `Text to insert the password into, ex. "customPrefix{{PASSWORD}}customSuffix".`, 101 | Deprecated: true, 102 | } 103 | return fields 104 | } 105 | 106 | func (b *backend) configUpdateOperation(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 107 | 108 | conf, err := readConfig(ctx, req.Storage) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | if conf == nil { 114 | conf = new(configuration) 115 | conf.ADConf = new(client.ADConf) 116 | } 117 | 118 | // Use the existing ldap client config if it is set 119 | var existing *ldaputil.ConfigEntry 120 | if conf.ADConf != nil && conf.ADConf.ConfigEntry != nil { 121 | existing = conf.ADConf.ConfigEntry 122 | } 123 | 124 | // Build and validate the ldap conf. 125 | activeDirectoryConf, err := ldaputil.NewConfigEntry(existing, fieldData) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | if err := activeDirectoryConf.Validate(); err != nil { 131 | return nil, err 132 | } 133 | 134 | // Build the password conf. 135 | ttl := fieldData.Get("ttl").(int) 136 | maxTTL := fieldData.Get("max_ttl").(int) 137 | lastRotationTolerance := fieldData.Get("last_rotation_tolerance").(int) 138 | 139 | passwordPolicy := fieldData.Get("password_policy").(string) 140 | 141 | var length int 142 | if lengthRaw, ok := fieldData.GetOk("length"); ok { 143 | length = lengthRaw.(int) 144 | } else if passwordPolicy == "" { 145 | // If neither the length nor a password policy was provided, fall back 146 | // to the length's field data default value. 147 | length = fieldData.Get("length").(int) 148 | } 149 | 150 | formatter := fieldData.Get("formatter").(string) 151 | 152 | if pre111Val, ok := fieldData.GetOk("use_pre111_group_cn_behavior"); ok { 153 | activeDirectoryConf.UsePre111GroupCNBehavior = new(bool) 154 | *activeDirectoryConf.UsePre111GroupCNBehavior = pre111Val.(bool) 155 | } else { 156 | // Default to false 157 | activeDirectoryConf.UsePre111GroupCNBehavior = new(bool) 158 | } 159 | 160 | if ttl == 0 { 161 | ttl = int(b.System().DefaultLeaseTTL().Seconds()) 162 | } 163 | if maxTTL == 0 { 164 | maxTTL = int(b.System().MaxLeaseTTL().Seconds()) 165 | } 166 | if ttl > maxTTL { 167 | return nil, errors.New("ttl must be smaller than or equal to max_ttl") 168 | } 169 | if ttl < 1 { 170 | return nil, errors.New("ttl must be positive") 171 | } 172 | if maxTTL < 1 { 173 | return nil, errors.New("max_ttl must be positive") 174 | } 175 | 176 | passwordConf := passwordConf{ 177 | TTL: ttl, 178 | MaxTTL: maxTTL, 179 | Length: length, 180 | Formatter: formatter, 181 | PasswordPolicy: passwordPolicy, 182 | } 183 | err = passwordConf.validate() 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | config := configuration{ 189 | PasswordConf: passwordConf, 190 | ADConf: &client.ADConf{ 191 | ConfigEntry: activeDirectoryConf, 192 | }, 193 | LastRotationTolerance: lastRotationTolerance, 194 | } 195 | err = writeConfig(ctx, req.Storage, &config) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | // Respond with a 204. 201 | return nil, nil 202 | } 203 | 204 | func (b *backend) configReadOperation(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { 205 | config, err := readConfig(ctx, req.Storage) 206 | if err != nil { 207 | return nil, err 208 | } 209 | if config == nil { 210 | return nil, nil 211 | } 212 | 213 | // NOTE: 214 | // "password" is intentionally not returned by this endpoint, 215 | // as we lean away from returning sensitive information unless it's absolutely necessary. 216 | // Also, we don't return the full ADConf here because not all parameters are used by this engine. 217 | configMap := map[string]interface{}{ 218 | "url": config.ADConf.Url, 219 | "starttls": config.ADConf.StartTLS, 220 | "insecure_tls": config.ADConf.InsecureTLS, 221 | "certificate": config.ADConf.Certificate, 222 | "binddn": config.ADConf.BindDN, 223 | "userdn": config.ADConf.UserDN, 224 | "upndomain": config.ADConf.UPNDomain, 225 | "tls_min_version": config.ADConf.TLSMinVersion, 226 | "tls_max_version": config.ADConf.TLSMaxVersion, 227 | "last_rotation_tolerance": config.LastRotationTolerance, 228 | } 229 | if !config.ADConf.LastBindPasswordRotation.Equal(time.Time{}) { 230 | configMap["last_bind_password_rotation"] = config.ADConf.LastBindPasswordRotation 231 | } 232 | if config.ADConf.UsePre111GroupCNBehavior != nil { 233 | configMap["use_pre111_group_cn_behavior"] = *config.ADConf.UsePre111GroupCNBehavior 234 | } 235 | for k, v := range config.PasswordConf.Map() { 236 | configMap[k] = v 237 | } 238 | 239 | resp := &logical.Response{ 240 | Data: configMap, 241 | } 242 | return resp, nil 243 | } 244 | 245 | func (b *backend) configDeleteOperation(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { 246 | if err := req.Storage.Delete(ctx, configStorageKey); err != nil { 247 | return nil, err 248 | } 249 | return nil, nil 250 | } 251 | 252 | const ( 253 | configHelpSynopsis = ` 254 | Configure the AD server to connect to, along with password options. 255 | ` 256 | configHelpDescription = ` 257 | This endpoint allows you to configure the AD server to connect to and its 258 | configuration options. When you add, update, or delete a config, it takes 259 | immediate effect on all subsequent actions. It does not apply itself to roles 260 | or creds added in the past. 261 | 262 | The AD URL can use either the "ldap://" or "ldaps://" schema. In the former 263 | case, an unencrypted connection will be made with a default port of 389, unless 264 | the "starttls" parameter is set to true, in which case TLS will be used. In the 265 | latter case, a SSL connection will be established with a default port of 636. 266 | 267 | ## A NOTE ON ESCAPING 268 | 269 | It is up to the administrator to provide properly escaped DNs. This includes 270 | the user DN, bind DN for search, and so on. 271 | 272 | The only DN escaping performed by this backend is on usernames given at login 273 | time when they are inserted into the final bind DN, and uses escaping rules 274 | defined in RFC 4514. 275 | 276 | Additionally, Active Directory has escaping rules that differ slightly from the 277 | RFC; in particular it requires escaping of '#' regardless of position in the DN 278 | (the RFC only requires it to be escaped when it is the first character), and 279 | '=', which the RFC indicates can be escaped with a backslash, but does not 280 | contain in its set of required escapes. If you are using Active Directory and 281 | these appear in your usernames, please ensure that they are escaped, in 282 | addition to being properly escaped in your configured DNs. 283 | 284 | For reference, see https://www.ietf.org/rfc/rfc4514.txt and 285 | http://social.technet.microsoft.com/wiki/contents/articles/5312.active-directory-characters-to-escape.aspx 286 | ` 287 | ) 288 | -------------------------------------------------------------------------------- /plugin/path_checkout_sets.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/hashicorp/go-secure-stdlib/strutil" 12 | "github.com/hashicorp/vault/sdk/framework" 13 | "github.com/hashicorp/vault/sdk/helper/locksutil" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | ) 16 | 17 | const libraryPrefix = "library/" 18 | 19 | type librarySet struct { 20 | ServiceAccountNames []string `json:"service_account_names"` 21 | TTL time.Duration `json:"ttl"` 22 | MaxTTL time.Duration `json:"max_ttl"` 23 | DisableCheckInEnforcement bool `json:"disable_check_in_enforcement"` 24 | } 25 | 26 | // Validates ensures that a set meets our code assumptions that TTLs are set in 27 | // a way that makes sense, and that there's at least one service account. 28 | func (l *librarySet) Validate() error { 29 | if len(l.ServiceAccountNames) < 1 { 30 | return fmt.Errorf(`at least one service account must be configured`) 31 | } 32 | if l.MaxTTL > 0 { 33 | if l.MaxTTL < l.TTL { 34 | return fmt.Errorf(`max_ttl (%d seconds) may not be less than ttl (%d seconds)`, l.MaxTTL, l.TTL) 35 | } 36 | } 37 | return nil 38 | } 39 | 40 | func (b *backend) pathListSets() *framework.Path { 41 | return &framework.Path{ 42 | Pattern: libraryPrefix + "?$", 43 | Operations: map[logical.Operation]framework.OperationHandler{ 44 | logical.ListOperation: &framework.PathOperation{ 45 | Callback: b.setListOperation, 46 | }, 47 | }, 48 | HelpSynopsis: pathListSetsHelpSyn, 49 | HelpDescription: pathListSetsHelpDesc, 50 | } 51 | } 52 | 53 | func (b *backend) setListOperation(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { 54 | keys, err := req.Storage.List(ctx, libraryPrefix) 55 | if err != nil { 56 | return nil, err 57 | } 58 | return logical.ListResponse(keys), nil 59 | } 60 | 61 | func (b *backend) pathSets() *framework.Path { 62 | return &framework.Path{ 63 | Pattern: libraryPrefix + framework.GenericNameRegex("name"), 64 | Fields: map[string]*framework.FieldSchema{ 65 | "name": { 66 | Type: framework.TypeLowerCaseString, 67 | Description: "Name of the set.", 68 | Required: true, 69 | }, 70 | "service_account_names": { 71 | Type: framework.TypeCommaStringSlice, 72 | Description: "The username/logon name for the service accounts with which this set will be associated.", 73 | }, 74 | "ttl": { 75 | Type: framework.TypeDurationSecond, 76 | Description: "In seconds, the amount of time a check-out should last. Defaults to 24 hours.", 77 | Default: 24 * 60 * 60, // 24 hours 78 | }, 79 | "max_ttl": { 80 | Type: framework.TypeDurationSecond, 81 | Description: "In seconds, the max amount of time a check-out's renewals should last. Defaults to 24 hours.", 82 | Default: 24 * 60 * 60, // 24 hours 83 | }, 84 | "disable_check_in_enforcement": { 85 | Type: framework.TypeBool, 86 | Description: "Disable the default behavior of requiring that check-ins are performed by the entity that checked them out.", 87 | Default: false, 88 | }, 89 | }, 90 | Operations: map[logical.Operation]framework.OperationHandler{ 91 | logical.CreateOperation: &framework.PathOperation{ 92 | Callback: b.operationSetCreate, 93 | Summary: "Create a library set.", 94 | }, 95 | logical.UpdateOperation: &framework.PathOperation{ 96 | Callback: b.operationSetUpdate, 97 | Summary: "Update a library set.", 98 | }, 99 | logical.ReadOperation: &framework.PathOperation{ 100 | Callback: b.operationSetRead, 101 | Summary: "Read a library set.", 102 | }, 103 | logical.DeleteOperation: &framework.PathOperation{ 104 | Callback: b.operationSetDelete, 105 | Summary: "Delete a library set.", 106 | }, 107 | }, 108 | ExistenceCheck: b.operationSetExistenceCheck, 109 | HelpSynopsis: setHelpSynopsis, 110 | HelpDescription: setHelpDescription, 111 | } 112 | } 113 | 114 | func (b *backend) operationSetExistenceCheck(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (bool, error) { 115 | set, err := readSet(ctx, req.Storage, fieldData.Get("name").(string)) 116 | if err != nil { 117 | return false, err 118 | } 119 | return set != nil, nil 120 | } 121 | 122 | func (b *backend) operationSetCreate(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 123 | setName := fieldData.Get("name").(string) 124 | 125 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 126 | lock.Lock() 127 | defer lock.Unlock() 128 | 129 | serviceAccountNames := fieldData.Get("service_account_names").([]string) 130 | ttl := time.Duration(fieldData.Get("ttl").(int)) * time.Second 131 | maxTTL := time.Duration(fieldData.Get("max_ttl").(int)) * time.Second 132 | disableCheckInEnforcement := fieldData.Get("disable_check_in_enforcement").(bool) 133 | 134 | if len(serviceAccountNames) == 0 { 135 | return logical.ErrorResponse(`"service_account_names" must be provided`), nil 136 | } 137 | 138 | // Ensure these service accounts aren't already managed by another check-out set. 139 | for _, serviceAccountName := range serviceAccountNames { 140 | if _, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, serviceAccountName); err != nil { 141 | if err == errNotFound { 142 | // This is what we want to see. 143 | continue 144 | } 145 | return nil, err 146 | } 147 | return logical.ErrorResponse(fmt.Sprintf("%q is already managed by another set", serviceAccountName)), nil 148 | } 149 | 150 | set := &librarySet{ 151 | ServiceAccountNames: serviceAccountNames, 152 | TTL: ttl, 153 | MaxTTL: maxTTL, 154 | DisableCheckInEnforcement: disableCheckInEnforcement, 155 | } 156 | if err := set.Validate(); err != nil { 157 | return logical.ErrorResponse(err.Error()), nil 158 | } 159 | for _, serviceAccountName := range serviceAccountNames { 160 | if err := b.checkOutHandler.CheckIn(ctx, req.Storage, serviceAccountName); err != nil { 161 | return nil, err 162 | } 163 | } 164 | if err := storeSet(ctx, req.Storage, setName, set); err != nil { 165 | return nil, err 166 | } 167 | return nil, nil 168 | } 169 | 170 | func (b *backend) operationSetUpdate(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 171 | setName := fieldData.Get("name").(string) 172 | 173 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 174 | lock.Lock() 175 | defer lock.Unlock() 176 | 177 | newServiceAccountNamesRaw, newServiceAccountNamesSent := fieldData.GetOk("service_account_names") 178 | var newServiceAccountNames []string 179 | if newServiceAccountNamesSent { 180 | newServiceAccountNames = newServiceAccountNamesRaw.([]string) 181 | } 182 | 183 | ttlRaw, ttlSent := fieldData.GetOk("ttl") 184 | if !ttlSent { 185 | ttlRaw = fieldData.Schema["ttl"].Default 186 | } 187 | ttl := time.Duration(ttlRaw.(int)) * time.Second 188 | 189 | maxTTLRaw, maxTTLSent := fieldData.GetOk("max_ttl") 190 | if !maxTTLSent { 191 | maxTTLRaw = fieldData.Schema["max_ttl"].Default 192 | } 193 | maxTTL := time.Duration(maxTTLRaw.(int)) * time.Second 194 | 195 | disableCheckInEnforcementRaw, enforcementSent := fieldData.GetOk("disable_check_in_enforcement") 196 | if !enforcementSent { 197 | disableCheckInEnforcementRaw = false 198 | } 199 | disableCheckInEnforcement := disableCheckInEnforcementRaw.(bool) 200 | 201 | set, err := readSet(ctx, req.Storage, setName) 202 | if err != nil { 203 | return nil, err 204 | } 205 | if set == nil { 206 | return logical.ErrorResponse(fmt.Sprintf(`%q doesn't exist`, setName)), nil 207 | } 208 | 209 | var beingAdded []string 210 | var beingDeleted []string 211 | if newServiceAccountNamesSent { 212 | 213 | // For new service accounts we receive, before we check them in, ensure they're not in another set. 214 | beingAdded = strutil.Difference(newServiceAccountNames, set.ServiceAccountNames, true) 215 | for _, newServiceAccountName := range beingAdded { 216 | if _, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, newServiceAccountName); err != nil { 217 | if err == errNotFound { 218 | // Great, this validates that it's not in use in another set. 219 | continue 220 | } 221 | return nil, err 222 | } 223 | return logical.ErrorResponse(fmt.Sprintf("%q is already managed by another set", newServiceAccountName)), nil 224 | } 225 | 226 | // For service accounts we won't be handling anymore, before we delete them, ensure they're not checked out. 227 | beingDeleted = strutil.Difference(set.ServiceAccountNames, newServiceAccountNames, true) 228 | for _, prevServiceAccountName := range beingDeleted { 229 | checkOut, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, prevServiceAccountName) 230 | if err != nil { 231 | if err == errNotFound { 232 | // Nothing else to do here. 233 | continue 234 | } 235 | return nil, err 236 | } 237 | if !checkOut.IsAvailable { 238 | return logical.ErrorResponse(fmt.Sprintf(`"%s" can't be deleted because it is currently checked out'`, prevServiceAccountName)), nil 239 | } 240 | } 241 | set.ServiceAccountNames = newServiceAccountNames 242 | } 243 | 244 | if ttlSent { 245 | set.TTL = ttl 246 | } 247 | if maxTTLSent { 248 | set.MaxTTL = maxTTL 249 | } 250 | if enforcementSent { 251 | set.DisableCheckInEnforcement = disableCheckInEnforcement 252 | } 253 | if err := set.Validate(); err != nil { 254 | return logical.ErrorResponse(err.Error()), nil 255 | } 256 | 257 | // Now that we know we can take all these actions, let's take them. 258 | for _, newServiceAccountName := range beingAdded { 259 | if err := b.checkOutHandler.CheckIn(ctx, req.Storage, newServiceAccountName); err != nil { 260 | return nil, err 261 | } 262 | } 263 | for _, prevServiceAccountName := range beingDeleted { 264 | if err := b.checkOutHandler.Delete(ctx, req.Storage, prevServiceAccountName); err != nil { 265 | return nil, err 266 | } 267 | } 268 | if err := storeSet(ctx, req.Storage, setName, set); err != nil { 269 | return nil, err 270 | } 271 | return nil, nil 272 | } 273 | 274 | func (b *backend) operationSetRead(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 275 | setName := fieldData.Get("name").(string) 276 | 277 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 278 | lock.RLock() 279 | defer lock.RUnlock() 280 | 281 | set, err := readSet(ctx, req.Storage, setName) 282 | if err != nil { 283 | return nil, err 284 | } 285 | if set == nil { 286 | return nil, nil 287 | } 288 | return &logical.Response{ 289 | Data: map[string]interface{}{ 290 | "service_account_names": set.ServiceAccountNames, 291 | "ttl": int64(set.TTL.Seconds()), 292 | "max_ttl": int64(set.MaxTTL.Seconds()), 293 | "disable_check_in_enforcement": set.DisableCheckInEnforcement, 294 | }, 295 | }, nil 296 | } 297 | 298 | func (b *backend) operationSetDelete(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 299 | setName := fieldData.Get("name").(string) 300 | 301 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 302 | lock.Lock() 303 | defer lock.Unlock() 304 | 305 | set, err := readSet(ctx, req.Storage, setName) 306 | if err != nil { 307 | return nil, err 308 | } 309 | if set == nil { 310 | return nil, nil 311 | } 312 | // We need to remove all the items we'd stored for these service accounts. 313 | for _, serviceAccountName := range set.ServiceAccountNames { 314 | checkOut, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, serviceAccountName) 315 | if err != nil { 316 | if err == errNotFound { 317 | // Nothing else to do here. 318 | continue 319 | } 320 | return nil, err 321 | } 322 | if !checkOut.IsAvailable { 323 | return logical.ErrorResponse(fmt.Sprintf(`"%s" can't be deleted because it is currently checked out'`, serviceAccountName)), nil 324 | } 325 | } 326 | for _, serviceAccountName := range set.ServiceAccountNames { 327 | if err := b.checkOutHandler.Delete(ctx, req.Storage, serviceAccountName); err != nil { 328 | return nil, err 329 | } 330 | } 331 | if err := req.Storage.Delete(ctx, libraryPrefix+setName); err != nil { 332 | return nil, err 333 | } 334 | return nil, nil 335 | } 336 | 337 | // readSet is a helper method for reading a set from storage by name. 338 | // It's intended to be used anywhere in the plugin. It may return nil, nil if 339 | // a librarySet doesn't currently exist for a given setName. 340 | func readSet(ctx context.Context, storage logical.Storage, setName string) (*librarySet, error) { 341 | entry, err := storage.Get(ctx, libraryPrefix+setName) 342 | if err != nil { 343 | return nil, err 344 | } 345 | if entry == nil { 346 | return nil, nil 347 | } 348 | set := &librarySet{} 349 | if err := entry.DecodeJSON(set); err != nil { 350 | return nil, err 351 | } 352 | return set, nil 353 | } 354 | 355 | // storeSet stores a librarySet. 356 | func storeSet(ctx context.Context, storage logical.Storage, setName string, set *librarySet) error { 357 | entry, err := logical.StorageEntryJSON(libraryPrefix+setName, set) 358 | if err != nil { 359 | return err 360 | } 361 | return storage.Put(ctx, entry) 362 | } 363 | 364 | const ( 365 | setHelpSynopsis = ` 366 | Build a library of service accounts that can be checked out. 367 | ` 368 | setHelpDescription = ` 369 | This endpoint allows you to read, write, and delete individual sets of service accounts for check-out. 370 | Deleting a set of service accounts can only be performed if all its accounts are currently checked in. 371 | ` 372 | pathListSetsHelpSyn = ` 373 | List the name of each set of service accounts currently stored. 374 | ` 375 | pathListSetsHelpDesc = ` 376 | To learn which service accounts are being managed by Vault, list the set names using 377 | this endpoint. Then read any individual set by name to learn more. 378 | ` 379 | ) 380 | -------------------------------------------------------------------------------- /plugin/path_checkouts.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | metrics "github.com/hashicorp/go-metrics/compat" 12 | "github.com/hashicorp/vault/sdk/framework" 13 | "github.com/hashicorp/vault/sdk/helper/locksutil" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | ) 16 | 17 | const secretAccessKeyType = "creds" 18 | 19 | func (b *backend) pathSetCheckOut() *framework.Path { 20 | return &framework.Path{ 21 | Pattern: libraryPrefix + framework.GenericNameRegex("name") + "/check-out$", 22 | Fields: map[string]*framework.FieldSchema{ 23 | "name": { 24 | Type: framework.TypeLowerCaseString, 25 | Description: "Name of the set", 26 | Required: true, 27 | }, 28 | "ttl": { 29 | Type: framework.TypeDurationSecond, 30 | Description: "The length of time before the check-out will expire, in seconds.", 31 | }, 32 | }, 33 | Operations: map[logical.Operation]framework.OperationHandler{ 34 | logical.UpdateOperation: &framework.PathOperation{ 35 | Callback: b.operationSetCheckOut, 36 | Summary: "Check a service account out from the library.", 37 | }, 38 | }, 39 | HelpSynopsis: `Check a service account out from the library.`, 40 | } 41 | } 42 | 43 | func (b *backend) operationSetCheckOut(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 44 | setName := fieldData.Get("name").(string) 45 | 46 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 47 | lock.Lock() 48 | defer lock.Unlock() 49 | 50 | ttlPeriodRaw, ttlPeriodSent := fieldData.GetOk("ttl") 51 | if !ttlPeriodSent { 52 | ttlPeriodRaw = 0 53 | } 54 | requestedTTL := time.Duration(ttlPeriodRaw.(int)) * time.Second 55 | 56 | set, err := readSet(ctx, req.Storage, setName) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if set == nil { 61 | return logical.ErrorResponse(fmt.Sprintf(`%q doesn't exist`, setName)), nil 62 | } 63 | 64 | // Prepare the check-out we'd like to execute. 65 | ttl := set.TTL 66 | if ttlPeriodSent { 67 | switch { 68 | case set.TTL <= 0 && requestedTTL > 0: 69 | // The set's TTL is infinite and the caller requested a finite TTL. 70 | ttl = requestedTTL 71 | case set.TTL > 0 && requestedTTL < set.TTL: 72 | // The set's TTL isn't infinite and the caller requested a shorter TTL. 73 | ttl = requestedTTL 74 | } 75 | } 76 | newCheckOut := &CheckOut{ 77 | IsAvailable: false, 78 | BorrowerEntityID: req.EntityID, 79 | BorrowerClientToken: req.ClientToken, 80 | } 81 | 82 | // Check out the first service account available. 83 | for _, serviceAccountName := range set.ServiceAccountNames { 84 | if err := b.checkOutHandler.CheckOut(ctx, req.Storage, serviceAccountName, newCheckOut); err != nil { 85 | if err == errCheckedOut { 86 | continue 87 | } 88 | return nil, err 89 | } 90 | password, err := retrievePassword(ctx, req.Storage, serviceAccountName) 91 | if err != nil { 92 | return nil, err 93 | } 94 | respData := map[string]interface{}{ 95 | "service_account_name": serviceAccountName, 96 | "password": password, 97 | } 98 | internalData := map[string]interface{}{ 99 | "service_account_name": serviceAccountName, 100 | "set_name": setName, 101 | } 102 | resp := b.Backend.Secret(secretAccessKeyType).Response(respData, internalData) 103 | resp.Secret.Renewable = true 104 | resp.Secret.TTL = ttl 105 | resp.Secret.MaxTTL = set.MaxTTL 106 | return resp, nil 107 | } 108 | 109 | // If we arrived here, it's because we never had a hit for a service account that was available. 110 | // In case of customer issues, we need to make this easy to see and diagnose. 111 | b.Logger().Debug(fmt.Sprintf(`%q had no check-outs available`, setName)) 112 | metrics.IncrCounter([]string{"active directory", "check-out", "unavailable", setName}, 1) 113 | return logical.ErrorResponse("No service accounts available for check-out."), nil 114 | } 115 | 116 | func (b *backend) secretAccessKeys() *framework.Secret { 117 | return &framework.Secret{ 118 | Type: secretAccessKeyType, 119 | Fields: map[string]*framework.FieldSchema{ 120 | "service_account_name": { 121 | Type: framework.TypeString, 122 | Description: "Service account name", 123 | }, 124 | "password": { 125 | Type: framework.TypeString, 126 | Description: "Password", 127 | }, 128 | }, 129 | Renew: b.renewCheckOut, 130 | Revoke: b.endCheckOut, 131 | } 132 | } 133 | 134 | func (b *backend) renewCheckOut(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 135 | setName := req.Secret.InternalData["set_name"].(string) 136 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 137 | lock.RLock() 138 | defer lock.RUnlock() 139 | 140 | set, err := readSet(ctx, req.Storage, setName) 141 | if err != nil { 142 | return nil, err 143 | } 144 | if set == nil { 145 | return logical.ErrorResponse(fmt.Sprintf(`%q doesn't exist`, setName)), nil 146 | } 147 | 148 | serviceAccountName := req.Secret.InternalData["service_account_name"].(string) 149 | checkOut, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, serviceAccountName) 150 | if err != nil { 151 | return nil, err 152 | } 153 | if checkOut.IsAvailable { 154 | // It's possible that this renewal could be attempted after a check-in occurred either by this entity or by 155 | // another user with access to the "manage check-ins" endpoint that forcibly checked it back in. 156 | return logical.ErrorResponse(fmt.Sprintf("%s is already checked in, please call check-out to regain it", serviceAccountName)), nil 157 | } 158 | resp := &logical.Response{Secret: req.Secret} 159 | resp.Secret.TTL = set.TTL 160 | resp.Secret.MaxTTL = set.MaxTTL 161 | return resp, nil 162 | } 163 | 164 | func (b *backend) endCheckOut(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 165 | setName := req.Secret.InternalData["set_name"].(string) 166 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 167 | lock.Lock() 168 | defer lock.Unlock() 169 | 170 | serviceAccountName := req.Secret.InternalData["service_account_name"].(string) 171 | if err := b.checkOutHandler.CheckIn(ctx, req.Storage, serviceAccountName); err != nil { 172 | return nil, err 173 | } 174 | return nil, nil 175 | } 176 | 177 | func (b *backend) pathSetCheckIn() *framework.Path { 178 | return &framework.Path{ 179 | Pattern: libraryPrefix + framework.GenericNameRegex("name") + "/check-in$", 180 | Fields: map[string]*framework.FieldSchema{ 181 | "name": { 182 | Type: framework.TypeLowerCaseString, 183 | Description: "Name of the set.", 184 | Required: true, 185 | }, 186 | "service_account_names": { 187 | Type: framework.TypeCommaStringSlice, 188 | Description: "The username/logon name for the service accounts to check in.", 189 | }, 190 | }, 191 | Operations: map[logical.Operation]framework.OperationHandler{ 192 | logical.UpdateOperation: &framework.PathOperation{ 193 | Callback: b.operationCheckIn(false), 194 | Summary: "Check service accounts in to the library.", 195 | }, 196 | }, 197 | HelpSynopsis: `Check service accounts in to the library.`, 198 | } 199 | } 200 | 201 | func (b *backend) pathSetManageCheckIn() *framework.Path { 202 | return &framework.Path{ 203 | Pattern: libraryPrefix + "manage/" + framework.GenericNameRegex("name") + "/check-in$", 204 | Fields: map[string]*framework.FieldSchema{ 205 | "name": { 206 | Type: framework.TypeLowerCaseString, 207 | Description: "Name of the set.", 208 | Required: true, 209 | }, 210 | "service_account_names": { 211 | Type: framework.TypeCommaStringSlice, 212 | Description: "The username/logon name for the service accounts to check in.", 213 | }, 214 | }, 215 | Operations: map[logical.Operation]framework.OperationHandler{ 216 | logical.UpdateOperation: &framework.PathOperation{ 217 | Callback: b.operationCheckIn(true), 218 | Summary: "Check service accounts in to the library.", 219 | }, 220 | }, 221 | HelpSynopsis: `Force checking service accounts in to the library.`, 222 | } 223 | } 224 | 225 | func (b *backend) operationCheckIn(overrideCheckInEnforcement bool) framework.OperationFunc { 226 | return func(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 227 | setName := fieldData.Get("name").(string) 228 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 229 | lock.Lock() 230 | defer lock.Unlock() 231 | 232 | serviceAccountNamesRaw, serviceAccountNamesSent := fieldData.GetOk("service_account_names") 233 | var serviceAccountNames []string 234 | if serviceAccountNamesSent { 235 | serviceAccountNames = serviceAccountNamesRaw.([]string) 236 | } 237 | 238 | set, err := readSet(ctx, req.Storage, setName) 239 | if err != nil { 240 | return nil, err 241 | } 242 | if set == nil { 243 | return logical.ErrorResponse(fmt.Sprintf(`%q doesn't exist`, setName)), nil 244 | } 245 | 246 | // If check-in enforcement is overridden or disabled at the set level, we should consider it disabled. 247 | disableCheckInEnforcement := overrideCheckInEnforcement || set.DisableCheckInEnforcement 248 | 249 | // Track the service accounts we check in so we can include it in our response. 250 | toCheckIn := make([]string, 0) 251 | 252 | // Build and validate a list of service account names that we will be checking in. 253 | if len(serviceAccountNames) == 0 { 254 | // It's okay if the caller doesn't tell us which service accounts they 255 | // want to check in as long as they only have one checked out. 256 | // We'll assume that's the one they want to check in. 257 | for _, setServiceAccount := range set.ServiceAccountNames { 258 | checkOut, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, setServiceAccount) 259 | if err != nil { 260 | return nil, err 261 | } 262 | if checkOut.IsAvailable { 263 | continue 264 | } 265 | if !disableCheckInEnforcement && !checkinAuthorized(req, checkOut) { 266 | continue 267 | } 268 | toCheckIn = append(toCheckIn, setServiceAccount) 269 | } 270 | if len(toCheckIn) > 1 { 271 | return logical.ErrorResponse(`when multiple service accounts are checked out, the "service_account_names" to check in must be provided`), nil 272 | } 273 | } else { 274 | for _, serviceAccountName := range serviceAccountNames { 275 | checkOut, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, serviceAccountName) 276 | if err != nil { 277 | return nil, err 278 | } 279 | // First guard that they should be able to do anything at all. 280 | if !checkOut.IsAvailable && !disableCheckInEnforcement && !checkinAuthorized(req, checkOut) { 281 | return logical.ErrorResponse("%q can't be checked in because it wasn't checked out by the caller", serviceAccountName), nil 282 | } 283 | if checkOut.IsAvailable { 284 | continue 285 | } 286 | toCheckIn = append(toCheckIn, serviceAccountName) 287 | } 288 | } 289 | for _, serviceAccountName := range toCheckIn { 290 | if err := b.checkOutHandler.CheckIn(ctx, req.Storage, serviceAccountName); err != nil { 291 | return nil, err 292 | } 293 | } 294 | return &logical.Response{ 295 | Data: map[string]interface{}{ 296 | "check_ins": toCheckIn, 297 | }, 298 | }, nil 299 | } 300 | } 301 | 302 | func (b *backend) pathSetStatus() *framework.Path { 303 | return &framework.Path{ 304 | Pattern: libraryPrefix + framework.GenericNameRegex("name") + "/status$", 305 | Fields: map[string]*framework.FieldSchema{ 306 | "name": { 307 | Type: framework.TypeLowerCaseString, 308 | Description: "Name of the set.", 309 | Required: true, 310 | }, 311 | }, 312 | Operations: map[logical.Operation]framework.OperationHandler{ 313 | logical.ReadOperation: &framework.PathOperation{ 314 | Callback: b.operationSetStatus, 315 | Summary: "Check the status of the service accounts in a library set.", 316 | }, 317 | }, 318 | HelpSynopsis: `Check the status of the service accounts in a library.`, 319 | } 320 | } 321 | 322 | func (b *backend) operationSetStatus(ctx context.Context, req *logical.Request, fieldData *framework.FieldData) (*logical.Response, error) { 323 | setName := fieldData.Get("name").(string) 324 | lock := locksutil.LockForKey(b.checkOutLocks, setName) 325 | lock.RLock() 326 | defer lock.RUnlock() 327 | 328 | set, err := readSet(ctx, req.Storage, setName) 329 | if err != nil { 330 | return nil, err 331 | } 332 | if set == nil { 333 | return logical.ErrorResponse(fmt.Sprintf(`%q doesn't exist`, setName)), nil 334 | } 335 | respData := make(map[string]interface{}) 336 | 337 | for _, serviceAccountName := range set.ServiceAccountNames { 338 | checkOut, err := b.checkOutHandler.LoadCheckOut(ctx, req.Storage, serviceAccountName) 339 | if err != nil { 340 | return nil, err 341 | } 342 | 343 | status := map[string]interface{}{ 344 | "available": checkOut.IsAvailable, 345 | } 346 | if checkOut.IsAvailable { 347 | // We only omit all other fields if the checkout is currently available, 348 | // because they're only relevant to accounts that aren't checked out. 349 | respData[serviceAccountName] = status 350 | continue 351 | } 352 | if checkOut.BorrowerClientToken != "" { 353 | status["borrower_client_token"] = checkOut.BorrowerClientToken 354 | } 355 | if checkOut.BorrowerEntityID != "" { 356 | status["borrower_entity_id"] = checkOut.BorrowerEntityID 357 | } 358 | respData[serviceAccountName] = status 359 | } 360 | return &logical.Response{ 361 | Data: respData, 362 | }, nil 363 | } 364 | 365 | func checkinAuthorized(req *logical.Request, checkOut *CheckOut) bool { 366 | if checkOut.BorrowerEntityID != "" && req.EntityID != "" { 367 | if checkOut.BorrowerEntityID == req.EntityID { 368 | return true 369 | } 370 | } 371 | if checkOut.BorrowerClientToken != "" && req.ClientToken != "" { 372 | if checkOut.BorrowerClientToken == req.ClientToken { 373 | return true 374 | } 375 | } 376 | return false 377 | } 378 | -------------------------------------------------------------------------------- /plugin/backend_checkouts_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package plugin 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/hashicorp/vault/sdk/logical" 11 | ) 12 | 13 | // The AD library of service accounts that can be checked out 14 | // is a discrete set of features. This test suite provides 15 | // end-to-end tests of these interrelated endpoints. 16 | func TestCheckOuts(t *testing.T) { 17 | // Plant a config. 18 | t.Run("plant config", PlantConfig) 19 | 20 | // Exercise all set endpoints. 21 | t.Run("write set", WriteSet) 22 | t.Run("read set", ReadSet) 23 | t.Run("read set status", ReadSetStatus) 24 | t.Run("write set toggle off", WriteSetToggleOff) 25 | t.Run("read set toggle off", ReadSetToggleOff) 26 | t.Run("write conflicting set", WriteSetWithConflictingServiceAccounts) 27 | t.Run("list sets", ListSets) 28 | t.Run("delete set", DeleteSet) 29 | 30 | // Do some common updates on sets and ensure they work. 31 | t.Run("write set", WriteSet) 32 | t.Run("add service account", AddAnotherServiceAccount) 33 | t.Run("remove service account", RemoveServiceAccount) 34 | 35 | t.Run("check initial status", CheckInitialStatus) 36 | t.Run("check out account", PerformCheckOut) 37 | t.Run("check updated status", CheckUpdatedStatus) 38 | t.Run("normal check in", NormalCheckIn) 39 | t.Run("return to initial status", CheckInitialStatus) 40 | t.Run("check out again", PerformCheckOut) 41 | t.Run("check updated status", CheckUpdatedStatus) 42 | t.Run("force check in", ForceCheckIn) 43 | t.Run("check all are available", CheckInitialStatus) 44 | } 45 | 46 | // TestCheckOutRaces executes a whole bunch of calls at once and only looks for 47 | // races. Responses are ignored because they'll vary depending on execution order. 48 | func TestCheckOutRaces(t *testing.T) { 49 | if testing.Short() { 50 | t.Skip("skipping check for races in the checkout system due to short flag") 51 | } 52 | 53 | // Get 100 goroutines ready to go. 54 | numParallel := 100 55 | start := make(chan bool, 1) 56 | end := make(chan bool, numParallel) 57 | for i := 0; i < numParallel; i++ { 58 | go func() { 59 | <-start 60 | testBackend.HandleRequest(ctx, &logical.Request{ 61 | Operation: logical.CreateOperation, 62 | Path: libraryPrefix + "test-set", 63 | Storage: testStorage, 64 | Data: map[string]interface{}{ 65 | "service_account_names": []string{"tester1@example.com", "tester2@example.com"}, 66 | "ttl": "10h", 67 | "max_ttl": "11h", 68 | "disable_check_in_enforcement": true, 69 | }, 70 | }) 71 | testBackend.HandleRequest(ctx, &logical.Request{ 72 | Operation: logical.UpdateOperation, 73 | Path: libraryPrefix + "test-set", 74 | Storage: testStorage, 75 | Data: map[string]interface{}{ 76 | "service_account_names": []string{"tester1@example.com", "tester2@example.com", "tester3@example.com"}, 77 | }, 78 | }) 79 | testBackend.HandleRequest(ctx, &logical.Request{ 80 | Operation: logical.UpdateOperation, 81 | Path: libraryPrefix + "test-set", 82 | Storage: testStorage, 83 | Data: map[string]interface{}{ 84 | "service_account_names": []string{"tester1@example.com", "tester2@example.com"}, 85 | }, 86 | }) 87 | testBackend.HandleRequest(ctx, &logical.Request{ 88 | Operation: logical.ReadOperation, 89 | Path: libraryPrefix + "test-set", 90 | Storage: testStorage, 91 | }) 92 | testBackend.HandleRequest(ctx, &logical.Request{ 93 | Operation: logical.UpdateOperation, 94 | Path: libraryPrefix + "test-set", 95 | Storage: testStorage, 96 | Data: map[string]interface{}{ 97 | "service_account_names": []string{"tester1@example.com", "tester2@example.com"}, 98 | "ttl": "10h", 99 | "disable_check_in_enforcement": false, 100 | }, 101 | }) 102 | testBackend.HandleRequest(ctx, &logical.Request{ 103 | Operation: logical.ReadOperation, 104 | Path: libraryPrefix + "test-set", 105 | Storage: testStorage, 106 | }) 107 | testBackend.HandleRequest(ctx, &logical.Request{ 108 | Operation: logical.ReadOperation, 109 | Path: libraryPrefix + "test-set/status", 110 | Storage: testStorage, 111 | }) 112 | testBackend.HandleRequest(ctx, &logical.Request{ 113 | Operation: logical.CreateOperation, 114 | Path: libraryPrefix + "test-set2", 115 | Storage: testStorage, 116 | Data: map[string]interface{}{ 117 | "service_account_names": "tester1@example.com", 118 | }, 119 | }) 120 | testBackend.HandleRequest(ctx, &logical.Request{ 121 | Operation: logical.ListOperation, 122 | Path: libraryPrefix, 123 | Storage: testStorage, 124 | }) 125 | testBackend.HandleRequest(ctx, &logical.Request{ 126 | Operation: logical.DeleteOperation, 127 | Path: libraryPrefix + "test-set", 128 | Storage: testStorage, 129 | }) 130 | testBackend.HandleRequest(ctx, &logical.Request{ 131 | Operation: logical.ReadOperation, 132 | Path: libraryPrefix + "test-set/status", 133 | Storage: testStorage, 134 | }) 135 | testBackend.HandleRequest(ctx, &logical.Request{ 136 | Operation: logical.ReadOperation, 137 | Path: libraryPrefix + "test-set/check-out", 138 | Storage: testStorage, 139 | }) 140 | testBackend.HandleRequest(ctx, &logical.Request{ 141 | Operation: logical.ReadOperation, 142 | Path: libraryPrefix + "test-set/status", 143 | Storage: testStorage, 144 | }) 145 | testBackend.HandleRequest(ctx, &logical.Request{ 146 | Operation: logical.ReadOperation, 147 | Path: libraryPrefix + "test-set/check-in", 148 | Storage: testStorage, 149 | }) 150 | testBackend.HandleRequest(ctx, &logical.Request{ 151 | Operation: logical.ReadOperation, 152 | Path: libraryPrefix + "manage/test-set/check-in", 153 | Storage: testStorage, 154 | }) 155 | end <- true 156 | }() 157 | } 158 | 159 | // Start them all at once. 160 | close(start) 161 | 162 | // Wait for them all to finish. 163 | timer := time.NewTimer(15 * time.Second) 164 | for i := 0; i < numParallel; i++ { 165 | select { 166 | case <-timer.C: 167 | t.Fatal("test took more than 15 seconds, may be deadlocked") 168 | case <-end: 169 | continue 170 | } 171 | } 172 | } 173 | 174 | func WriteSet(t *testing.T) { 175 | req := &logical.Request{ 176 | Operation: logical.CreateOperation, 177 | Path: libraryPrefix + "test-set", 178 | Storage: testStorage, 179 | Data: map[string]interface{}{ 180 | "service_account_names": []string{"tester1@example.com", "tester2@example.com"}, 181 | "ttl": "10h", 182 | "max_ttl": "11h", 183 | "disable_check_in_enforcement": true, 184 | }, 185 | } 186 | resp, err := testBackend.HandleRequest(ctx, req) 187 | if err != nil || (resp != nil && resp.IsError()) { 188 | t.Fatal(err) 189 | } 190 | if resp != nil { 191 | t.Fatalf("expected an empty response, got: %v", resp) 192 | } 193 | } 194 | 195 | func AddAnotherServiceAccount(t *testing.T) { 196 | req := &logical.Request{ 197 | Operation: logical.UpdateOperation, 198 | Path: libraryPrefix + "test-set", 199 | Storage: testStorage, 200 | Data: map[string]interface{}{ 201 | "service_account_names": []string{"tester1@example.com", "tester2@example.com", "tester3@example.com"}, 202 | }, 203 | } 204 | resp, err := testBackend.HandleRequest(ctx, req) 205 | if err != nil || (resp != nil && resp.IsError()) { 206 | t.Fatal(err) 207 | } 208 | if resp != nil { 209 | t.Fatalf("expected an empty response, got: %v", resp) 210 | } 211 | } 212 | 213 | func RemoveServiceAccount(t *testing.T) { 214 | req := &logical.Request{ 215 | Operation: logical.UpdateOperation, 216 | Path: libraryPrefix + "test-set", 217 | Storage: testStorage, 218 | Data: map[string]interface{}{ 219 | "service_account_names": []string{"tester1@example.com", "tester2@example.com"}, 220 | }, 221 | } 222 | resp, err := testBackend.HandleRequest(ctx, req) 223 | if err != nil || (resp != nil && resp.IsError()) { 224 | t.Fatal(err) 225 | } 226 | if resp != nil { 227 | t.Fatalf("expected an empty response, got: %v", resp) 228 | } 229 | } 230 | 231 | func ReadSet(t *testing.T) { 232 | req := &logical.Request{ 233 | Operation: logical.ReadOperation, 234 | Path: libraryPrefix + "test-set", 235 | Storage: testStorage, 236 | } 237 | resp, err := testBackend.HandleRequest(ctx, req) 238 | if err != nil || (resp != nil && resp.IsError()) { 239 | t.Fatal(err) 240 | } 241 | if resp == nil { 242 | t.Fatal("expected a response") 243 | } 244 | serviceAccountNames := resp.Data["service_account_names"].([]string) 245 | if len(serviceAccountNames) != 2 { 246 | t.Fatal("expected 2") 247 | } 248 | disableCheckInEnforcement := resp.Data["disable_check_in_enforcement"].(bool) 249 | if !disableCheckInEnforcement { 250 | t.Fatal("check-in enforcement should be disabled") 251 | } 252 | ttl := resp.Data["ttl"].(int64) 253 | if ttl != 10*60*60 { // 10 hours 254 | t.Fatal(ttl) 255 | } 256 | maxTTL := resp.Data["max_ttl"].(int64) 257 | if maxTTL != 11*60*60 { // 11 hours 258 | t.Fatal(maxTTL) 259 | } 260 | } 261 | 262 | func WriteSetToggleOff(t *testing.T) { 263 | req := &logical.Request{ 264 | Operation: logical.UpdateOperation, 265 | Path: libraryPrefix + "test-set", 266 | Storage: testStorage, 267 | Data: map[string]interface{}{ 268 | "service_account_names": []string{"tester1@example.com", "tester2@example.com"}, 269 | "ttl": "10h", 270 | "disable_check_in_enforcement": false, 271 | }, 272 | } 273 | resp, err := testBackend.HandleRequest(ctx, req) 274 | if err != nil || (resp != nil && resp.IsError()) { 275 | t.Fatal(err) 276 | } 277 | if resp != nil { 278 | t.Fatalf("expected an empty response, got: %v", resp) 279 | } 280 | } 281 | 282 | func ReadSetToggleOff(t *testing.T) { 283 | req := &logical.Request{ 284 | Operation: logical.ReadOperation, 285 | Path: libraryPrefix + "test-set", 286 | Storage: testStorage, 287 | } 288 | resp, err := testBackend.HandleRequest(ctx, req) 289 | if err != nil || (resp != nil && resp.IsError()) { 290 | t.Fatal(err) 291 | } 292 | if resp == nil { 293 | t.Fatal("expected a response") 294 | } 295 | serviceAccountNames := resp.Data["service_account_names"].([]string) 296 | if len(serviceAccountNames) != 2 { 297 | t.Fatal("expected 2") 298 | } 299 | disableCheckInEnforcement := resp.Data["disable_check_in_enforcement"].(bool) 300 | if disableCheckInEnforcement { 301 | t.Fatal("check-in enforcement should be enabled") 302 | } 303 | } 304 | 305 | func ReadSetStatus(t *testing.T) { 306 | req := &logical.Request{ 307 | Operation: logical.ReadOperation, 308 | Path: libraryPrefix + "test-set/status", 309 | Storage: testStorage, 310 | } 311 | resp, err := testBackend.HandleRequest(ctx, req) 312 | if err != nil || (resp != nil && resp.IsError()) { 313 | t.Fatal(err) 314 | } 315 | if resp == nil { 316 | t.Fatal("expected a response") 317 | } 318 | if len(resp.Data) != 2 { 319 | t.Fatal("length should be 2 because there are two service accounts in this set") 320 | } 321 | if resp.Data["tester1@example.com"] == nil { 322 | t.Fatal("expected non-nil map") 323 | } 324 | testerStatus := resp.Data["tester1@example.com"].(map[string]interface{}) 325 | if !testerStatus["available"].(bool) { 326 | t.Fatal("should be available for checkout") 327 | } 328 | } 329 | 330 | func WriteSetWithConflictingServiceAccounts(t *testing.T) { 331 | req := &logical.Request{ 332 | Operation: logical.CreateOperation, 333 | Path: libraryPrefix + "test-set2", 334 | Storage: testStorage, 335 | Data: map[string]interface{}{ 336 | "service_account_names": "tester1@example.com", 337 | }, 338 | } 339 | resp, err := testBackend.HandleRequest(ctx, req) 340 | if err != nil { 341 | t.Fatal(err) 342 | } 343 | if resp == nil || !resp.IsError() { 344 | t.Fatal("expected err response because we're adding a service account managed by another set") 345 | } 346 | } 347 | 348 | func ListSets(t *testing.T) { 349 | req := &logical.Request{ 350 | Operation: logical.ListOperation, 351 | Path: libraryPrefix, 352 | Storage: testStorage, 353 | } 354 | resp, err := testBackend.HandleRequest(ctx, req) 355 | if err != nil || (resp != nil && resp.IsError()) { 356 | t.Fatal(err) 357 | } 358 | if resp == nil { 359 | t.Fatal("expected a response") 360 | } 361 | if resp.Data["keys"] == nil { 362 | t.Fatal("expected non-nil data") 363 | } 364 | listedKeys := resp.Data["keys"].([]string) 365 | if len(listedKeys) != 1 { 366 | t.Fatalf("expected 1 key but received %s", listedKeys) 367 | } 368 | if "test-set" != listedKeys[0] { 369 | t.Fatal("expected test-set to be the only listed item") 370 | } 371 | } 372 | 373 | func DeleteSet(t *testing.T) { 374 | req := &logical.Request{ 375 | Operation: logical.DeleteOperation, 376 | Path: libraryPrefix + "test-set", 377 | Storage: testStorage, 378 | } 379 | resp, err := testBackend.HandleRequest(ctx, req) 380 | if err != nil || (resp != nil && resp.IsError()) { 381 | t.Fatal(err) 382 | } 383 | if resp != nil { 384 | t.Fatalf("expected an empty response, got: %v", resp) 385 | } 386 | } 387 | 388 | func CheckInitialStatus(t *testing.T) { 389 | req := &logical.Request{ 390 | Operation: logical.ReadOperation, 391 | Path: libraryPrefix + "test-set/status", 392 | Storage: testStorage, 393 | } 394 | resp, err := testBackend.HandleRequest(ctx, req) 395 | if err != nil || (resp != nil && resp.IsError()) { 396 | t.Fatal(err) 397 | } 398 | if resp == nil { 399 | t.Fatal("expected a response") 400 | } 401 | if resp.Data["tester1@example.com"] == nil { 402 | t.Fatal("expected map to not be nil") 403 | } 404 | tester1CheckOut := resp.Data["tester1@example.com"].(map[string]interface{}) 405 | available := tester1CheckOut["available"].(bool) 406 | if !available { 407 | t.Fatal("tester1 should be available") 408 | } 409 | 410 | if resp.Data["tester2@example.com"] == nil { 411 | t.Fatal("expected map to not be nil") 412 | } 413 | tester2CheckOut := resp.Data["tester2@example.com"].(map[string]interface{}) 414 | available = tester2CheckOut["available"].(bool) 415 | if !available { 416 | t.Fatal("tester2 should be available") 417 | } 418 | } 419 | 420 | func PerformCheckOut(t *testing.T) { 421 | req := &logical.Request{ 422 | Operation: logical.UpdateOperation, 423 | Path: libraryPrefix + "test-set/check-out", 424 | Storage: testStorage, 425 | } 426 | resp, err := testBackend.HandleRequest(ctx, req) 427 | if err != nil || (resp != nil && resp.IsError()) { 428 | t.Fatal(err) 429 | } 430 | if resp == nil { 431 | t.Fatal("expected a response") 432 | } 433 | if resp.Data == nil { 434 | t.Fatal("expected resp data to not be nil") 435 | } 436 | 437 | if resp.Data["service_account_name"] == nil { 438 | t.Fatal("expected string to be populated") 439 | } 440 | if resp.Data["service_account_name"].(string) == "" { 441 | t.Fatal("service account name should be populated") 442 | } 443 | if resp.Data["password"].(string) == "" { 444 | t.Fatal("password should be populated") 445 | } 446 | if !resp.Secret.Renewable { 447 | t.Fatal("lease should be renewable") 448 | } 449 | if resp.Secret.TTL != time.Hour*10 { 450 | t.Fatal("expected 10h TTL") 451 | } 452 | if resp.Secret.MaxTTL != time.Hour*11 { 453 | t.Fatal("expected 11h TTL") 454 | } 455 | if resp.Secret.InternalData["service_account_name"].(string) == "" { 456 | t.Fatal("internal service account name should not be empty") 457 | } 458 | } 459 | 460 | func CheckUpdatedStatus(t *testing.T) { 461 | req := &logical.Request{ 462 | Operation: logical.ReadOperation, 463 | Path: libraryPrefix + "test-set/status", 464 | Storage: testStorage, 465 | } 466 | resp, err := testBackend.HandleRequest(ctx, req) 467 | if err != nil || (resp != nil && resp.IsError()) { 468 | t.Fatal(err) 469 | } 470 | if resp == nil { 471 | t.Fatal("expected a response") 472 | } 473 | if resp.Data == nil { 474 | t.Fatal("expected data to not be nil") 475 | } 476 | 477 | if resp.Data["tester1@example.com"] == nil { 478 | t.Fatal("expected map to not be nil") 479 | } 480 | tester1CheckOut := resp.Data["tester1@example.com"].(map[string]interface{}) 481 | tester1Available := tester1CheckOut["available"].(bool) 482 | 483 | if resp.Data["tester2@example.com"] == nil { 484 | t.Fatal("expected map to not be nil") 485 | } 486 | tester2CheckOut := resp.Data["tester2@example.com"].(map[string]interface{}) 487 | tester2Available := tester2CheckOut["available"].(bool) 488 | 489 | if tester1Available && tester2Available { 490 | t.Fatal("one of the testers should not be available") 491 | } 492 | } 493 | 494 | func NormalCheckIn(t *testing.T) { 495 | req := &logical.Request{ 496 | Operation: logical.UpdateOperation, 497 | Path: libraryPrefix + "test-set/check-in", 498 | Storage: testStorage, 499 | } 500 | resp, err := testBackend.HandleRequest(ctx, req) 501 | if err != nil || (resp != nil && resp.IsError()) { 502 | t.Fatal(err) 503 | } 504 | if resp == nil { 505 | t.Fatal("expected a response") 506 | } 507 | checkIns := resp.Data["check_ins"].([]string) 508 | if len(checkIns) != 1 { 509 | t.Fatal("expected 1 check-in") 510 | } 511 | } 512 | 513 | func ForceCheckIn(t *testing.T) { 514 | req := &logical.Request{ 515 | Operation: logical.UpdateOperation, 516 | Path: libraryPrefix + "manage/test-set/check-in", 517 | Storage: testStorage, 518 | } 519 | resp, err := testBackend.HandleRequest(ctx, req) 520 | if err != nil || (resp != nil && resp.IsError()) { 521 | t.Fatal(err) 522 | } 523 | if resp == nil { 524 | t.Fatal("expected a response") 525 | } 526 | checkIns := resp.Data["check_ins"].([]string) 527 | if len(checkIns) != 1 { 528 | t.Fatal("expected 1 check-in") 529 | } 530 | } 531 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 HashiCorp, Inc. 2 | 3 | Mozilla Public License Version 2.0 4 | ================================== 5 | 6 | 1. Definitions 7 | -------------- 8 | 9 | 1.1. "Contributor" 10 | means each individual or legal entity that creates, contributes to 11 | the creation of, or owns Covered Software. 12 | 13 | 1.2. "Contributor Version" 14 | means the combination of the Contributions of others (if any) used 15 | by a Contributor and that particular Contributor's Contribution. 16 | 17 | 1.3. "Contribution" 18 | means Covered Software of a particular Contributor. 19 | 20 | 1.4. "Covered Software" 21 | means Source Code Form to which the initial Contributor has attached 22 | the notice in Exhibit A, the Executable Form of such Source Code 23 | Form, and Modifications of such Source Code Form, in each case 24 | including portions thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | (a) that the initial Contributor has attached the notice described 30 | in Exhibit B to the Covered Software; or 31 | 32 | (b) that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the 34 | terms of a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | means any form of the work other than Source Code Form. 38 | 39 | 1.7. "Larger Work" 40 | means a work that combines Covered Software with other material, in 41 | a separate file or files, that is not Covered Software. 42 | 43 | 1.8. "License" 44 | means this document. 45 | 46 | 1.9. "Licensable" 47 | means having the right to grant, to the maximum extent possible, 48 | whether at the time of the initial grant or subsequently, any and 49 | all of the rights conveyed by this License. 50 | 51 | 1.10. "Modifications" 52 | means any of the following: 53 | 54 | (a) any file in Source Code Form that results from an addition to, 55 | deletion from, or modification of the contents of Covered 56 | Software; or 57 | 58 | (b) any new file in Source Code Form that contains any Covered 59 | Software. 60 | 61 | 1.11. "Patent Claims" of a Contributor 62 | means any patent claim(s), including without limitation, method, 63 | process, and apparatus claims, in any patent Licensable by such 64 | Contributor that would be infringed, but for the grant of the 65 | License, by the making, using, selling, offering for sale, having 66 | made, import, or transfer of either its Contributions or its 67 | Contributor Version. 68 | 69 | 1.12. "Secondary License" 70 | means either the GNU General Public License, Version 2.0, the GNU 71 | Lesser General Public License, Version 2.1, the GNU Affero General 72 | Public License, Version 3.0, or any later versions of those 73 | licenses. 74 | 75 | 1.13. "Source Code Form" 76 | means the form of the work preferred for making modifications. 77 | 78 | 1.14. "You" (or "Your") 79 | means an individual or a legal entity exercising rights under this 80 | License. For legal entities, "You" includes any entity that 81 | controls, is controlled by, or is under common control with You. For 82 | purposes of this definition, "control" means (a) the power, direct 83 | or indirect, to cause the direction or management of such entity, 84 | whether by contract or otherwise, or (b) ownership of more than 85 | fifty percent (50%) of the outstanding shares or beneficial 86 | ownership of such entity. 87 | 88 | 2. License Grants and Conditions 89 | -------------------------------- 90 | 91 | 2.1. Grants 92 | 93 | Each Contributor hereby grants You a world-wide, royalty-free, 94 | non-exclusive license: 95 | 96 | (a) under intellectual property rights (other than patent or trademark) 97 | Licensable by such Contributor to use, reproduce, make available, 98 | modify, display, perform, distribute, and otherwise exploit its 99 | Contributions, either on an unmodified basis, with Modifications, or 100 | as part of a Larger Work; and 101 | 102 | (b) under Patent Claims of such Contributor to make, use, sell, offer 103 | for sale, have made, import, and otherwise transfer either its 104 | Contributions or its Contributor Version. 105 | 106 | 2.2. Effective Date 107 | 108 | The licenses granted in Section 2.1 with respect to any Contribution 109 | become effective for each Contribution on the date the Contributor first 110 | distributes such Contribution. 111 | 112 | 2.3. Limitations on Grant Scope 113 | 114 | The licenses granted in this Section 2 are the only rights granted under 115 | this License. No additional rights or licenses will be implied from the 116 | distribution or licensing of Covered Software under this License. 117 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 118 | Contributor: 119 | 120 | (a) for any code that a Contributor has removed from Covered Software; 121 | or 122 | 123 | (b) for infringements caused by: (i) Your and any other third party's 124 | modifications of Covered Software, or (ii) the combination of its 125 | Contributions with other software (except as part of its Contributor 126 | Version); or 127 | 128 | (c) under Patent Claims infringed by Covered Software in the absence of 129 | its Contributions. 130 | 131 | This License does not grant any rights in the trademarks, service marks, 132 | or logos of any Contributor (except as may be necessary to comply with 133 | the notice requirements in Section 3.4). 134 | 135 | 2.4. Subsequent Licenses 136 | 137 | No Contributor makes additional grants as a result of Your choice to 138 | distribute the Covered Software under a subsequent version of this 139 | License (see Section 10.2) or under the terms of a Secondary License (if 140 | permitted under the terms of Section 3.3). 141 | 142 | 2.5. Representation 143 | 144 | Each Contributor represents that the Contributor believes its 145 | Contributions are its original creation(s) or it has sufficient rights 146 | to grant the rights to its Contributions conveyed by this License. 147 | 148 | 2.6. Fair Use 149 | 150 | This License is not intended to limit any rights You have under 151 | applicable copyright doctrines of fair use, fair dealing, or other 152 | equivalents. 153 | 154 | 2.7. Conditions 155 | 156 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 157 | in Section 2.1. 158 | 159 | 3. Responsibilities 160 | ------------------- 161 | 162 | 3.1. Distribution of Source Form 163 | 164 | All distribution of Covered Software in Source Code Form, including any 165 | Modifications that You create or to which You contribute, must be under 166 | the terms of this License. You must inform recipients that the Source 167 | Code Form of the Covered Software is governed by the terms of this 168 | License, and how they can obtain a copy of this License. You may not 169 | attempt to alter or restrict the recipients' rights in the Source Code 170 | Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | (a) such Covered Software must also be made available in Source Code 177 | Form, as described in Section 3.1, and You must inform recipients of 178 | the Executable Form how they can obtain a copy of such Source Code 179 | Form by reasonable means in a timely manner, at a charge no more 180 | than the cost of distribution to the recipient; and 181 | 182 | (b) You may distribute such Executable Form under the terms of this 183 | License, or sublicense it under different terms, provided that the 184 | license for the Executable Form does not attempt to limit or alter 185 | the recipients' rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for 191 | the Covered Software. If the Larger Work is a combination of Covered 192 | Software with a work governed by one or more Secondary Licenses, and the 193 | Covered Software is not Incompatible With Secondary Licenses, this 194 | License permits You to additionally distribute such Covered Software 195 | under the terms of such Secondary License(s), so that the recipient of 196 | the Larger Work may, at their option, further distribute the Covered 197 | Software under the terms of either this License or such Secondary 198 | License(s). 199 | 200 | 3.4. Notices 201 | 202 | You may not remove or alter the substance of any license notices 203 | (including copyright notices, patent notices, disclaimers of warranty, 204 | or limitations of liability) contained within the Source Code Form of 205 | the Covered Software, except that You may alter any license notices to 206 | the extent required to remedy known factual inaccuracies. 207 | 208 | 3.5. Application of Additional Terms 209 | 210 | You may choose to offer, and to charge a fee for, warranty, support, 211 | indemnity or liability obligations to one or more recipients of Covered 212 | Software. However, You may do so only on Your own behalf, and not on 213 | behalf of any Contributor. You must make it absolutely clear that any 214 | such warranty, support, indemnity, or liability obligation is offered by 215 | You alone, and You hereby agree to indemnify every Contributor for any 216 | liability incurred by such Contributor as a result of warranty, support, 217 | indemnity or liability terms You offer. You may include additional 218 | disclaimers of warranty and limitations of liability specific to any 219 | jurisdiction. 220 | 221 | 4. Inability to Comply Due to Statute or Regulation 222 | --------------------------------------------------- 223 | 224 | If it is impossible for You to comply with any of the terms of this 225 | License with respect to some or all of the Covered Software due to 226 | statute, judicial order, or regulation then You must: (a) comply with 227 | the terms of this License to the maximum extent possible; and (b) 228 | describe the limitations and the code they affect. Such description must 229 | be placed in a text file included with all distributions of the Covered 230 | Software under this License. Except to the extent prohibited by statute 231 | or regulation, such description must be sufficiently detailed for a 232 | recipient of ordinary skill to be able to understand it. 233 | 234 | 5. Termination 235 | -------------- 236 | 237 | 5.1. The rights granted under this License will terminate automatically 238 | if You fail to comply with any of its terms. However, if You become 239 | compliant, then the rights granted under this License from a particular 240 | Contributor are reinstated (a) provisionally, unless and until such 241 | Contributor explicitly and finally terminates Your grants, and (b) on an 242 | ongoing basis, if such Contributor fails to notify You of the 243 | non-compliance by some reasonable means prior to 60 days after You have 244 | come back into compliance. Moreover, Your grants from a particular 245 | Contributor are reinstated on an ongoing basis if such Contributor 246 | notifies You of the non-compliance by some reasonable means, this is the 247 | first time You have received notice of non-compliance with this License 248 | from such Contributor, and You become compliant prior to 30 days after 249 | Your receipt of the notice. 250 | 251 | 5.2. If You initiate litigation against any entity by asserting a patent 252 | infringement claim (excluding declaratory judgment actions, 253 | counter-claims, and cross-claims) alleging that a Contributor Version 254 | directly or indirectly infringes any patent, then the rights granted to 255 | You by any and all Contributors for the Covered Software under Section 256 | 2.1 of this License shall terminate. 257 | 258 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 259 | end user license agreements (excluding distributors and resellers) which 260 | have been validly granted by You or Your distributors under this License 261 | prior to termination shall survive termination. 262 | 263 | ************************************************************************ 264 | * * 265 | * 6. Disclaimer of Warranty * 266 | * ------------------------- * 267 | * * 268 | * Covered Software is provided under this License on an "as is" * 269 | * basis, without warranty of any kind, either expressed, implied, or * 270 | * statutory, including, without limitation, warranties that the * 271 | * Covered Software is free of defects, merchantable, fit for a * 272 | * particular purpose or non-infringing. The entire risk as to the * 273 | * quality and performance of the Covered Software is with You. * 274 | * Should any Covered Software prove defective in any respect, You * 275 | * (not any Contributor) assume the cost of any necessary servicing, * 276 | * repair, or correction. This disclaimer of warranty constitutes an * 277 | * essential part of this License. No use of any Covered Software is * 278 | * authorized under this License except under this disclaimer. * 279 | * * 280 | ************************************************************************ 281 | 282 | ************************************************************************ 283 | * * 284 | * 7. Limitation of Liability * 285 | * -------------------------- * 286 | * * 287 | * Under no circumstances and under no legal theory, whether tort * 288 | * (including negligence), contract, or otherwise, shall any * 289 | * Contributor, or anyone who distributes Covered Software as * 290 | * permitted above, be liable to You for any direct, indirect, * 291 | * special, incidental, or consequential damages of any character * 292 | * including, without limitation, damages for lost profits, loss of * 293 | * goodwill, work stoppage, computer failure or malfunction, or any * 294 | * and all other commercial damages or losses, even if such party * 295 | * shall have been informed of the possibility of such damages. This * 296 | * limitation of liability shall not apply to liability for death or * 297 | * personal injury resulting from such party's negligence to the * 298 | * extent applicable law prohibits such limitation. Some * 299 | * jurisdictions do not allow the exclusion or limitation of * 300 | * incidental or consequential damages, so this exclusion and * 301 | * limitation may not apply to You. * 302 | * * 303 | ************************************************************************ 304 | 305 | 8. Litigation 306 | ------------- 307 | 308 | Any litigation relating to this License may be brought only in the 309 | courts of a jurisdiction where the defendant maintains its principal 310 | place of business and such litigation shall be governed by laws of that 311 | jurisdiction, without reference to its conflict-of-law provisions. 312 | Nothing in this Section shall prevent a party's ability to bring 313 | cross-claims or counter-claims. 314 | 315 | 9. Miscellaneous 316 | ---------------- 317 | 318 | This License represents the complete agreement concerning the subject 319 | matter hereof. If any provision of this License is held to be 320 | unenforceable, such provision shall be reformed only to the extent 321 | necessary to make it enforceable. Any law or regulation which provides 322 | that the language of a contract shall be construed against the drafter 323 | shall not be used to construe this License against a Contributor. 324 | 325 | 10. Versions of the License 326 | --------------------------- 327 | 328 | 10.1. New Versions 329 | 330 | Mozilla Foundation is the license steward. Except as provided in Section 331 | 10.3, no one other than the license steward has the right to modify or 332 | publish new versions of this License. Each version will be given a 333 | distinguishing version number. 334 | 335 | 10.2. Effect of New Versions 336 | 337 | You may distribute the Covered Software under the terms of the version 338 | of the License under which You originally received the Covered Software, 339 | or under the terms of any subsequent version published by the license 340 | steward. 341 | 342 | 10.3. Modified Versions 343 | 344 | If you create software not governed by this License, and you want to 345 | create a new license for such software, you may create and use a 346 | modified version of this License if you rename the license and remove 347 | any references to the name of the license steward (except to note that 348 | such modified license differs from this License). 349 | 350 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 351 | Licenses 352 | 353 | If You choose to distribute Source Code Form that is Incompatible With 354 | Secondary Licenses under the terms of this version of the License, the 355 | notice described in Exhibit B of this License must be attached. 356 | 357 | Exhibit A - Source Code Form License Notice 358 | ------------------------------------------- 359 | 360 | This Source Code Form is subject to the terms of the Mozilla Public 361 | License, v. 2.0. If a copy of the MPL was not distributed with this 362 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 363 | 364 | If it is not possible or desirable to put the notice in a particular 365 | file, then You may include the notice in a location (such as a LICENSE 366 | file in a relevant directory) where a recipient would be likely to look 367 | for such a notice. 368 | 369 | You may add additional accurate notices of copyright ownership. 370 | 371 | Exhibit B - "Incompatible With Secondary Licenses" Notice 372 | --------------------------------------------------------- 373 | 374 | This Source Code Form is "Incompatible With Secondary Licenses", as 375 | defined by the Mozilla Public License, v. 2.0. 376 | --------------------------------------------------------------------------------