├── .dockerignore ├── .github └── CODEOWNERS ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── NOTICES.txt ├── README.md ├── SECURITY.md ├── bin ├── dev.sh ├── package.sh ├── start-conjur.sh ├── test.sh └── utils.sh ├── conjurapi ├── authn.go ├── authn │ ├── api_key_authenticator.go │ ├── api_key_authenticator_test.go │ ├── auth_token.go │ ├── auth_token_test.go │ ├── jwt_authenticator.go │ ├── jwt_authenticator_test.go │ ├── oidc_authenticator.go │ ├── oidc_authenticator_test.go │ ├── token_authenticator.go │ ├── token_authenticator_test.go │ ├── token_file_authenticator.go │ ├── token_file_authenticator_test.go │ ├── wait_for_file.go │ └── wait_for_file_test.go ├── authn_test.go ├── client.go ├── client_test.go ├── config.go ├── config_test.go ├── env_test.go ├── host_factory.go ├── host_factory_test.go ├── info.go ├── info_test.go ├── issuer.go ├── issuer_test.go ├── logging │ ├── logging.go │ └── logging_test.go ├── mock_conjur_test.go ├── policy.go ├── policy_test.go ├── requests.go ├── requests_test.go ├── resource.go ├── resource_json_test.go ├── resource_test.go ├── response │ ├── error.go │ ├── error_test.go │ ├── response.go │ └── response_test.go ├── role.go ├── role_test.go ├── router_url.go ├── router_url_test.go ├── storage.go ├── storage │ ├── keyring_storage_provider.go │ ├── keyring_storage_provider_test.go │ ├── netrc_storage_provider.go │ └── netrc_storage_provider_test.go ├── storage_test.go ├── utils_test.go ├── variable.go ├── variable_test.go ├── version.go └── version_test.go ├── docker-compose.yml ├── go.mod ├── go.sum └── kics.config /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | output/ 3 | vendor/ 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cyberark/community-and-integrations-team @conjurinc/community-and-integrations-team @conjurdemos/community-and-integrations-team @conjur-enterprise/community-and-integrations 2 | 3 | # Changes to .trivyignore require Security Architect approval 4 | .trivyignore @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects @conjur-enterprise/conjur-security 5 | 6 | # Changes to .codeclimate.yml require Quality Architect approval 7 | .codeclimate.yml @cyberark/quality-architects @conjurinc/quality-architects @conjurdemos/quality-architects @conjur-enterprise/conjur-quality 8 | 9 | # Changes to SECURITY.md require Security Architect approval 10 | SECURITY.md @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects @conjur-enterprise/conjur-security 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | /output/ 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | For general contribution and community guidelines, please see the [community repo](https://github.com/cyberark/community). 4 | 5 | ## Contributing 6 | 7 | 1. [Fork the project](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) 8 | 2. [Clone your fork](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) 9 | 3. Make local changes to your fork by editing files 10 | 3. [Commit your changes](https://help.github.com/en/github/managing-files-in-a-repository/adding-a-file-to-a-repository-using-the-command-line) 11 | 4. [Push your local changes to the remote server](https://help.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) 12 | 5. [Create new Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) 13 | 14 | From here your pull request will be reviewed and once you've responded to all 15 | feedback it will be merged into the project. Congratulations, you're a 16 | contributor! 17 | 18 | ## Development 19 | To start developing and testing using our development scripts , 20 | the following tools need to be installed: 21 | 22 | - Docker 23 | - docker-compose 24 | 25 | ### Running tests 26 | 27 | To run the test suite, run: 28 | ```shell 29 | ./bin/test.sh 30 | ``` 31 | 32 | This will spin up a containerized Conjur environment and build the test containers, 33 | and will run all tests. 34 | 35 | To run the tests against a specific version of Golang, you can run the following: 36 | ```shell 37 | ./bin/test.sh 1.21 38 | ``` 39 | 40 | This will spin up a containerized Conjur environment and build the test containers, 41 | and will run the tests in a `golang:1.23` container 42 | 43 | Supported arguments are `1.23` and `1.24`, with the 44 | default being `1.23` if no argument is given. 45 | 46 | ### Setting up a development environment 47 | To start a container with terminal access, and the necessary 48 | test running dependencies installed, run: 49 | 50 | ```shell 51 | ./bin/dev.sh 52 | ``` 53 | 54 | You can then run the following command from the container terminal to run 55 | all tests: 56 | 57 | ```shell 58 | go test -coverprofile="output/c.out" -v ./... | tee output/junit.output; 59 | exit_code=$?; 60 | echo "Exit code: $exit_code" 61 | ``` 62 | 63 | ## Releases 64 | 65 | Releases should be created by maintainers only. To create a tag and release, 66 | follow the instructions in this section. 67 | 68 | ### Pre-requisites 69 | 70 | 1. Review the git log and ensure the [changelog](CHANGELOG.md) contains all 71 | relevant recent changes with references to GitHub issues or PRs, if possible. 72 | Also ensure the latest unreleased version is accurate - our pipeline generates 73 | a VERSION file based on the changelog, which is then used to assign the version 74 | of the release and any release artifacts. 75 | 1. Review the changes since the last tag, and if the dependencies have changed 76 | revise the [NOTICES](NOTICES.txt) to correctly capture the included 77 | dependencies and their licenses / copyrights. 78 | 1. Ensure that all documentation that needs to be written has been 79 | written by TW, approved by PO/Engineer, and pushed to the forward-facing documentation. 80 | 1. Scan the project for vulnerabilities 81 | 82 | ### Release and Promote 83 | 84 | 1. Merging into main/master branches will automatically trigger a release. If successful, this release can be promoted at a later time. 85 | 1. Jenkins build parameters can be utilized to promote a successful release or manually trigger aditional releases as needed. 86 | 1. Reference the [internal automated release doc](https://github.com/conjurinc/docs/blob/master/reference/infrastructure/automated_releases.md#release-and-promotion-process) for releasing and promoting. 87 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG FROM_IMAGE="golang:1.23" 2 | FROM ${FROM_IMAGE} 3 | LABEL maintainer="CyberArk Software Ltd." 4 | 5 | CMD ["/bin/bash"] 6 | EXPOSE 8080 7 | 8 | RUN apt-get update -y && \ 9 | apt-get install -y --no-install-recommends \ 10 | bash \ 11 | gcc \ 12 | git \ 13 | jq \ 14 | less \ 15 | libc-dev 16 | 17 | RUN go install github.com/jstemmer/go-junit-report@latest && \ 18 | go install github.com/axw/gocov/gocov@latest && \ 19 | go install github.com/AlekSi/gocov-xml@latest && \ 20 | go install github.com/wadey/gocovmerge@latest 21 | 22 | WORKDIR /conjur-api-go 23 | 24 | COPY go.mod go.sum ./ 25 | RUN go mod download 26 | 27 | COPY . . 28 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env groovy 2 | @Library("product-pipelines-shared-library") _ 3 | 4 | // Automated release, promotion and dependencies 5 | properties([ 6 | // Include the automated release parameters for the build 7 | release.addParams(), 8 | // Dependencies of the project that should trigger builds 9 | dependencies([]) 10 | ]) 11 | 12 | // Performs release promotion. No other stages will be run 13 | if (params.MODE == "PROMOTE") { 14 | release.promote(params.VERSION_TO_PROMOTE) { sourceVersion, targetVersion, assetDirectory -> 15 | 16 | } 17 | // Copy Github Enterprise release to Github 18 | release.copyEnterpriseRelease(params.VERSION_TO_PROMOTE) 19 | return 20 | } 21 | 22 | pipeline { 23 | agent { label 'conjur-enterprise-common-agent' } 24 | 25 | options { 26 | timestamps() 27 | buildDiscarder(logRotator(numToKeepStr: '30')) 28 | } 29 | 30 | triggers { 31 | parameterizedCron(getDailyCronString("%TEST_CLOUD=true")) 32 | } 33 | 34 | environment { 35 | // Sets the MODE to the specified or autocalculated value as appropriate 36 | MODE = release.canonicalizeMode() 37 | } 38 | 39 | parameters { 40 | booleanParam(name: 'TEST_CLOUD', defaultValue: false, description: 'Run integration tests against a Conjur Cloud tenant') 41 | } 42 | 43 | stages { 44 | // Aborts any builds triggered by another project that wouldn't include any changes 45 | stage ("Skip build if triggering job didn't create a release") { 46 | when { 47 | expression { 48 | MODE == "SKIP" 49 | } 50 | } 51 | steps { 52 | script { 53 | currentBuild.result = 'ABORTED' 54 | error("Aborting build because this build was triggered from upstream, but no release was built") 55 | } 56 | } 57 | } 58 | stage('Scan for internal URLs') { 59 | steps { 60 | script { 61 | detectInternalUrls() 62 | } 63 | } 64 | } 65 | 66 | stage('Get InfraPool ExecutorV2 Agent') { 67 | steps { 68 | script { 69 | // Request ExecutorV2 agents for 1 hour(s) 70 | INFRAPOOL_EXECUTORV2_AGENTS = getInfraPoolAgent(type: "ExecutorV2", quantity: 1, duration: 1) 71 | INFRAPOOL_EXECUTORV2_AGENT_0 = INFRAPOOL_EXECUTORV2_AGENTS[0] 72 | infrapool = infraPoolConnect(INFRAPOOL_EXECUTORV2_AGENT_0, {}) 73 | } 74 | } 75 | } 76 | 77 | // Generates a VERSION file based on the current build number and latest version in CHANGELOG.md 78 | stage('Validate Changelog and set version') { 79 | steps { 80 | script { 81 | updateVersion(infrapool, "CHANGELOG.md", "${BUILD_NUMBER}") 82 | } 83 | } 84 | } 85 | 86 | stage('Run Tests') { 87 | environment { 88 | // Currently, we're not updating DockerHub during version releases/promotions, which we need to fix. 89 | // Added a switch in Jenkinsfile and test configurations to toggle between registry.tld for internal testing and docker.io for using the conjur:edge image externally. 90 | // Tests default to using DockerHub images. In our internal Jenkins setup, this is overridden to pull from our internal registry instead. 91 | REGISTRY_URL = "registry.tld" 92 | } 93 | parallel { 94 | stage('Golang 1.24') { 95 | steps { 96 | script { 97 | infrapool.agentSh "./bin/test.sh 1.24 $REGISTRY_URL" 98 | infrapool.agentStash name: '1.24-out', includes: 'output/1.24/*.xml' 99 | unstash '1.24-out' 100 | } 101 | } 102 | } 103 | 104 | stage('Golang 1.23') { 105 | steps { 106 | script { 107 | infrapool.agentSh "./bin/test.sh 1.23 $REGISTRY_URL" 108 | infrapool.agentStash name: '1.23-out', includes: 'output/1.23/*.xml' 109 | unstash '1.23-out' 110 | cobertura autoUpdateHealth: false, 111 | autoUpdateStability: false, 112 | coberturaReportFile: 'output/1.23/coverage.xml', 113 | conditionalCoverageTargets: '30, 0, 0', 114 | failUnhealthy: true, 115 | failUnstable: false, 116 | lineCoverageTargets: '30, 0, 0', 117 | maxNumberOfBuilds: 0, 118 | methodCoverageTargets: '30, 0, 0', 119 | onlyStable: false, 120 | sourceEncoding: 'ASCII', 121 | zoomCoverageChart: false 122 | infrapool.agentSh 'cp output/1.23/c.out .' 123 | codacy action: 'reportCoverage', filePath: "output/1.23/coverage.xml" 124 | } 125 | } 126 | } 127 | } 128 | post { 129 | always { 130 | junit 'output/1.24/junit.xml, output/1.23/junit.xml' 131 | } 132 | } 133 | } 134 | 135 | stage('Run Conjur Cloud tests') { 136 | when { 137 | expression { params.TEST_CLOUD } 138 | } 139 | stages { 140 | stage('Create a Tenant') { 141 | steps { 142 | script { 143 | TENANT = getConjurCloudTenant() 144 | } 145 | } 146 | } 147 | stage('Authenticate') { 148 | steps { 149 | script { 150 | def id_token = getConjurCloudTenant.tokens( 151 | infrapool: infrapool, 152 | identity_url: "${TENANT.identity_information.idaptive_tenant_fqdn}", 153 | username: "${TENANT.login_name}" 154 | ) 155 | 156 | def conj_token = getConjurCloudTenant.tokens( 157 | infrapool: infrapool, 158 | conjur_url: "${TENANT.conjur_cloud_url}", 159 | identity_token: "${id_token}" 160 | ) 161 | 162 | env.identity_token = id_token 163 | env.conj_token = conj_token 164 | } 165 | } 166 | } 167 | stage('Run tests against Tenant') { 168 | environment { 169 | INFRAPOOL_CONJUR_APPLIANCE_URL="${TENANT.conjur_cloud_url}" 170 | INFRAPOOL_CONJUR_AUTHN_LOGIN="${TENANT.login_name}" 171 | INFRAPOOL_CONJUR_AUTHN_TOKEN="${env.conj_token}" 172 | INFRAPOOL_IDENTITY_TOKEN="${env.identity_token}" 173 | INFRAPOOL_TEST_CLOUD=true 174 | } 175 | steps { 176 | script { 177 | infrapool.agentSh "./bin/test.sh" 178 | infrapool.agentStash name: 'merged-out', includes: 'output/cloud/*.xml' 179 | unstash 'merged-out' 180 | cobertura autoUpdateHealth: false, 181 | autoUpdateStability: false, 182 | coberturaReportFile: 'output/cloud/merged-coverage.xml', 183 | conditionalCoverageTargets: '30, 0, 0', 184 | failUnhealthy: true, 185 | failUnstable: false, 186 | lineCoverageTargets: '30, 0, 0', 187 | maxNumberOfBuilds: 0, 188 | methodCoverageTargets: '30, 0, 0', 189 | onlyStable: false, 190 | sourceEncoding: 'ASCII', 191 | zoomCoverageChart: false 192 | infrapool.agentSh 'cp output/cloud/merged-coverage.out .' 193 | codacy action: 'reportCoverage', filePath: "output/cloud/merged-coverage.xml" 194 | } 195 | } 196 | } 197 | } 198 | post { 199 | always { 200 | junit 'output/cloud/junit.xml' 201 | script { 202 | deleteConjurCloudTenant("${TENANT.id}") 203 | } 204 | } 205 | } 206 | } 207 | 208 | stage('Package distribution tarballs') { 209 | steps { 210 | script { 211 | infrapool.agentSh './bin/package.sh' 212 | infrapool.agentArchiveArtifacts artifacts: 'output/dist/*', fingerprint: true 213 | } 214 | } 215 | } 216 | 217 | stage('Release') { 218 | when { 219 | expression { 220 | MODE == "RELEASE" 221 | } 222 | } 223 | steps { 224 | script { 225 | release(infrapool) { billOfMaterialsDirectory, assetDirectory, toolsDirectory -> 226 | // Publish release artifacts to all the appropriate locations 227 | 228 | // Copy any artifacts to assetDirectory to attach them to the Github release 229 | infrapool.agentSh "cp -r output/dist/* ${assetDirectory}" 230 | 231 | // Create Go module SBOM 232 | infrapool.agentSh """export PATH="${toolsDirectory}/bin:${PATH}" && go-bom --tools "${toolsDirectory}" --go-mod ./go.mod --image "golang" --output "${billOfMaterialsDirectory}/go-mod-bom.json" """ 233 | } 234 | } 235 | } 236 | } 237 | } 238 | 239 | post { 240 | always { 241 | releaseInfraPoolAgent(".infrapool/release_agents") 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # conjurapi 2 | 3 | Programmatic Golang access to the Conjur API. 4 | 5 | ## Certification level 6 | ![](https://img.shields.io/badge/Certification%20Level-Community-28A745?link=https://github.com/cyberark/community/blob/master/Conjur/conventions/certification-levels.md) 7 | 8 | This repo is a **Community** level project. It's a community contributed project that **is not reviewed or supported 9 | by CyberArk**. For more detailed information on our certification levels, see [our community guidelines](https://github.com/cyberark/community/blob/master/Conjur/conventions/certification-levels.md#community). 10 | 11 | ## Using conjur-api-go with Conjur Open Source 12 | 13 | Are you using this project with [Conjur Open Source](https://github.com/cyberark/conjur)? Then we 14 | **strongly** recommend choosing the version of this project to use from the latest [Conjur OSS 15 | suite release](https://docs.conjur.org/Latest/en/Content/Overview/Conjur-OSS-Suite-Overview.html). 16 | Conjur maintainers perform additional testing on the suite release versions to ensure 17 | compatibility. When possible, upgrade your Conjur version to match the 18 | [latest suite release](https://docs.conjur.org/Latest/en/Content/ReleaseNotes/ConjurOSS-suite-RN.htm); 19 | when using integrations, choose the latest suite release that matches your Conjur version. For any 20 | questions, please contact us on [Discourse](https://discuss.cyberarkcommons.org/c/conjur/5). 21 | 22 | ## Compatibility 23 | 24 | The `conjur-api-go` has been tested against the following Go versions: 25 | 26 | - 1.23 27 | - 1.24 28 | 29 | ## Installation 30 | 31 | ``` 32 | $ go get github.com/cyberark/conjur-api-go/conjurapi 33 | ``` 34 | 35 | ## Quick start 36 | 37 | Fetching a Secret, for example: 38 | 39 | Suppose there exists a variable `db/secret` with secret value `fde5c4a45ce573f9768987cd` 40 | 41 | Create a go program using `conjur-api-go` to fetch the secret value: 42 | 43 | ```go 44 | package main 45 | 46 | import ( 47 | "os" 48 | "fmt" 49 | "github.com/cyberark/conjur-api-go/conjurapi" 50 | "github.com/cyberark/conjur-api-go/conjurapi/authn" 51 | ) 52 | 53 | func main() { 54 | variableIdentifier := "db/secret" 55 | 56 | config, err := conjurapi.LoadConfig() 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | conjur, err := conjurapi.NewClientFromKey(config, 62 | authn.LoginPair{ 63 | Login: os.Getenv("CONJUR_AUTHN_LOGIN"), 64 | APIKey: os.Getenv("CONJUR_AUTHN_API_KEY"), 65 | }, 66 | ) 67 | if err != nil { 68 | panic(err) 69 | } 70 | 71 | // Retrieve a secret into []byte. 72 | secretValue, err := conjur.RetrieveSecret(variableIdentifier) 73 | if err != nil { 74 | panic(err) 75 | } 76 | fmt.Println("The secret value is: ", string(secretValue)) 77 | 78 | // Retrieve a secret into io.ReadCloser, then read into []byte. 79 | // Alternatively, you can transfer the secret directly into secure memory, 80 | // vault, keychain, etc. 81 | secretResponse, err := conjur.RetrieveSecretReader(variableIdentifier) 82 | if err != nil { 83 | panic(err) 84 | } 85 | 86 | secretValue, err = conjurapi.ReadResponseBody(secretResponse) 87 | if err != nil { 88 | panic(err) 89 | } 90 | fmt.Println("The secret value is: ", string(secretValue)) 91 | } 92 | ``` 93 | 94 | Build and run the program: 95 | 96 | ```bash 97 | $ export CONJUR_APPLIANCE_URL=https://eval.conjur.org 98 | $ export CONJUR_ACCOUNT=myorg 99 | $ export CONJUR_AUTHN_LOGIN=mylogin 100 | $ export CONJUR_AUTHN_API_KEY=myapikey 101 | $ go run main.go 102 | The secret value is: fde5c4a45ce573f9768987cd 103 | ``` 104 | ## Usage 105 | 106 | Connecting to Conjur is a two-step process: 107 | 108 | * **Configuration** Instruct the API where to find the Conjur endpoint and how to secure the connection. 109 | * **Authentication** Provide the API with credentials that it can use to authenticate. 110 | 111 | ### Authenticating with authn-jwt via Environment Variables 112 | 113 | #### Example Code 114 | 115 | ```go 116 | package main 117 | 118 | import ( 119 | "fmt" 120 | "log" 121 | "os" 122 | 123 | "github.com/cyberark/conjur-api-go/conjurapi" 124 | ) 125 | 126 | // Environment variables to define: 127 | // CONJUR_APPLIANCE_URL, CONJUR_ACCOUNT, CONJUR_AUTHN_JWT_SERVICE_ID, 128 | // CONJUR_AUTHN_JWT_TOKEN, CONJUR_SECRET_ID 129 | 130 | func checkEnvironmentVariables() error { 131 | // Check for environment variables and error if one is missing. 132 | variables := []string{ 133 | "CONJUR_APPLIANCE_URL", 134 | "CONJUR_ACCOUNT", 135 | "CONJUR_AUTHN_JWT_SERVICE_ID", 136 | "CONJUR_AUTHN_JWT_TOKEN", 137 | "CONJUR_SECRET_ID", 138 | } 139 | 140 | for _, variable := range variables { 141 | if os.Getenv(variable) == "" { 142 | return fmt.Errorf("Environment variable %s is not set", variable) 143 | } 144 | } 145 | 146 | return nil 147 | } 148 | 149 | func main() { 150 | 151 | // Check for environment variables and error if one is missing. 152 | err := checkEnvironmentVariables() 153 | if err != nil { 154 | log.Fatalf("%v", err) 155 | } 156 | 157 | // Defining secret ID to retrieve, as per 12 factor 158 | // this is being accomplished via env variables. 159 | variableIdentifier := os.Getenv("CONJUR_SECRET_ID") 160 | 161 | // Loading configuration via defined Env vars: 162 | // CONJUR_APPLIANCE & CONJUR_ACCOUNT 163 | config, err := conjurapi.LoadConfig() 164 | if err != nil { 165 | log.Fatalf("Cannot load configuration. %s", err) 166 | } 167 | 168 | // Create a new Conjur client using environment variables 169 | conjur, err := conjurapi.NewClientFromEnvironment(config) 170 | if err != nil { 171 | log.Fatalf("Cannot create new client from environment variables. %s", err) 172 | } 173 | 174 | // Retrieve the secret value from Conjur 175 | secretValue, err := conjur.RetrieveSecret(variableIdentifier) 176 | if err != nil { 177 | log.Fatalf("Cannot retrieve secret value for %s. %s", variableIdentifier, err) 178 | } 179 | 180 | // Print the secret value to stdout 181 | fmt.Printf("%s", string(secretValue)) 182 | } 183 | ``` 184 | 185 | ## Contributing 186 | 187 | We welcome contributions of all kinds to this repository. For instructions on how to get started and descriptions of our development workflows, please see our [contributing 188 | guide][contrib]. 189 | 190 | [contrib]: https://github.com/cyberark/conjur-api-go/blob/main/CONTRIBUTING.md 191 | 192 | ## License 193 | 194 | Copyright (c) 2022-2024 CyberArk Software Ltd. All rights reserved. 195 | 196 | This repository is licensed under Apache License 2.0 - see [`LICENSE`](LICENSE) for more details. 197 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policies and Procedures 2 | 3 | This document outlines security procedures and general policies for the CyberArk Conjur 4 | suite of tools and products. 5 | 6 | * [Reporting a Bug](#reporting-a-bug) 7 | * [Disclosure Policy](#disclosure-policy) 8 | * [Comments on this Policy](#comments-on-this-policy) 9 | 10 | ## Reporting a Bug 11 | 12 | The CyberArk Conjur team and community take all security bugs in the Conjur suite seriously. 13 | Thank you for improving the security of the Conjur suite. We appreciate your efforts and 14 | responsible disclosure and will make every effort to acknowledge your 15 | contributions. 16 | 17 | Report security bugs by emailing the lead maintainers at security@conjur.org. 18 | 19 | The maintainers will acknowledge your email within 2 business days. Subsequently, we will 20 | send a more detailed response within 2 business days of our acknowledgement indicating 21 | the next steps in handling your report. After the initial reply to your report, the security 22 | team will endeavor to keep you informed of the progress towards a fix and full 23 | announcement, and may ask for additional information or guidance. 24 | 25 | Report security bugs in third-party modules to the person or team maintaining 26 | the module. 27 | 28 | ## Disclosure Policy 29 | 30 | When the security team receives a security bug report, they will assign it to a 31 | primary handler. This person will coordinate the fix and release process, 32 | involving the following steps: 33 | 34 | * Confirm the problem and determine the affected versions. 35 | * Audit code to find any potential similar problems. 36 | * Prepare fixes for all releases still under maintenance. These fixes will be 37 | released as fast as possible. 38 | 39 | ## Comments on this Policy 40 | 41 | If you have suggestions on how this process could be improved please submit a 42 | pull request. 43 | -------------------------------------------------------------------------------- /bin/dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -ex 2 | 3 | cd "$(dirname "$0")" 4 | . ./utils.sh 5 | 6 | source ./start-conjur.sh 7 | 8 | docker compose build dev 9 | docker compose up --no-deps -d dev 10 | 11 | # When we start the dev container, it mounts the top-level directory in 12 | # the container. This excludes the vendored dependencies that got 13 | # installed during the build, so reinstall them. 14 | exec_on dev go mod download 15 | 16 | # Start interactive container 17 | docker exec -it \ 18 | -e CONJUR_AUTHN_API_KEY \ 19 | "$(docker compose ps -q dev)" /bin/bash 20 | -------------------------------------------------------------------------------- /bin/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd "$(dirname "$0")" 4 | 5 | echo "==> Packaging..." 6 | build_dir="../output/dist" 7 | rm -rf "$build_dir" 8 | mkdir -p "$build_dir" 9 | 10 | tar --exclude='../.git' --exclude='../output' -cvzf "$build_dir/conjur-api-go.tar.gz" . 11 | 12 | # # Make the checksums 13 | echo "==> Checksumming..." 14 | pushd "$build_dir" 15 | if which sha256sum; then 16 | sha256sum * > SHA256SUMS.txt 17 | elif which shasum; then 18 | shasum -a256 * > SHA256SUMS.txt 19 | else 20 | echo "couldn't find sha256sum or shasum" 21 | exit 1 22 | fi 23 | popd 24 | -------------------------------------------------------------------------------- /bin/start-conjur.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | . ./utils.sh 4 | 5 | trap teardown ERR 6 | 7 | announce "Compose Project Name: $COMPOSE_PROJECT_NAME" 8 | 9 | main() { 10 | announce "Pulling images..." 11 | docker compose pull "conjur" "postgres" 12 | echo "Done!" 13 | 14 | announce "Building images..." 15 | docker compose build "conjur" "postgres" 16 | echo "Done!" 17 | 18 | announce "Starting Conjur environment..." 19 | export CONJUR_DATA_KEY="$(docker compose run -T --no-deps conjur data-key generate)" 20 | docker compose up --no-deps -d "conjur" "postgres" 21 | echo "Done!" 22 | 23 | announce "Waiting for conjur to start..." 24 | exec_on conjur conjurctl wait 25 | 26 | echo "Done!" 27 | 28 | api_key=$(exec_on conjur conjurctl role retrieve-key conjur:user:admin | tr -d '\r') 29 | 30 | # Export values needed for tests to access Conjur instance 31 | export CONJUR_AUTHN_API_KEY="$api_key" 32 | } 33 | 34 | main 35 | -------------------------------------------------------------------------------- /bin/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd "$(dirname "$0")" 4 | source ./utils.sh 5 | 6 | trap teardown EXIT 7 | 8 | # unique for pipeline; repeatable & meaningful for dev 9 | PROJECT_SUFFIX="$(openssl rand -hex 3)" 10 | 11 | # To selectively choose packages and tests, export these envs before call: 12 | # API_PKGS such as "./..." or "./conjurapi" 13 | # will be passed like 'go test -v ' 14 | # API_TESTS such as "TestClient_LoadPolicy" 15 | # will be used like 'go test -run ' 16 | 17 | # default to no tests specified, all packages 18 | PKGS="-v ./..." 19 | TESTS="" 20 | if [ "$API_PKGS" != "" ]; then 21 | PKGS="-v ${API_PKGS}" 22 | fi 23 | if [ "$API_TESTS" != "" ]; then 24 | TESTS="-run ${API_TESTS}" 25 | PROJECT_SUFFIX=$(project_nameable "$API_TESTS") 26 | fi 27 | 28 | export COMPOSE_PROJECT_NAME="conjurapigo_${PROJECT_SUFFIX}" 29 | export GO_VERSION="${1:-"1.23"}" 30 | export REGISTRY_URL="${2:-docker.io}" 31 | echo "REGISTRY_URL is set to: $REGISTRY_URL" 32 | 33 | init_jwt_server 34 | 35 | if [ -z "$INFRAPOOL_TEST_CLOUD" ]; then 36 | # Spin up Conjur environment 37 | source ./start-conjur.sh 38 | 39 | announce "Building test containers..." 40 | docker compose build "test-$GO_VERSION" 41 | echo "Done!" 42 | 43 | # generate output folder locally, if needed 44 | output_dir="../output/$GO_VERSION" 45 | mkdir -p $output_dir 46 | 47 | # We are using package list mode of 'go test' 48 | # note: the expression must be eval'ed before passing to docker 49 | export TEST_PKGS="$PKGS $TESTS" 50 | 51 | announce "Running tests for Go version: $GO_VERSION..."; 52 | echo "Package and test selection: $TEST_PKGS" 53 | 54 | docker compose run \ 55 | --rm \ 56 | --no-deps \ 57 | -e CONJUR_AUTHN_API_KEY \ 58 | -e GO_VERSION \ 59 | -e PUBLIC_KEYS \ 60 | -e JWT \ 61 | -e TEST_PKGS \ 62 | "test-$GO_VERSION" bash -c 'set -o pipefail; 63 | echo "Go version: $(go version)" 64 | output_dir="./output/$GO_VERSION" 65 | go test -coverprofile="$output_dir/c.out" $TEST_PKGS | tee "$output_dir/junit.output"; 66 | exit_code=$?; 67 | echo "Tests finished - aggregating results..."; 68 | cat "$output_dir/junit.output" | go-junit-report > "$output_dir/junit.xml"; 69 | gocov convert "$output_dir/c.out" | gocov-xml > "$output_dir/coverage.xml"; 70 | [ "$exit_code" -eq 0 ]' || failed 71 | else 72 | # Export INFRAPOOL env vars for Cloud tests 73 | export CONJUR_APPLIANCE_URL=$INFRAPOOL_CONJUR_APPLIANCE_URL 74 | export CONJUR_ACCOUNT=conjur 75 | export CONJUR_AUTHN_LOGIN=$INFRAPOOL_CONJUR_AUTHN_LOGIN 76 | export CONJUR_AUTHN_TOKEN=$(echo "$INFRAPOOL_CONJUR_AUTHN_TOKEN" | base64 --decode) 77 | export IDENTITY_TOKEN=$INFRAPOOL_IDENTITY_TOKEN 78 | 79 | output_dir="../output/cloud" 80 | mkdir -p $output_dir 81 | 82 | docker build \ 83 | --build-arg FROM_IMAGE="golang:$GO_VERSION" \ 84 | -t "test-$GO_VERSION" .. 85 | 86 | announce "Running Conjur Cloud tests for Go version: $GO_VERSION..."; 87 | docker run \ 88 | -e CONJUR_APPLIANCE_URL \ 89 | -e CONJUR_ACCOUNT \ 90 | -e CONJUR_AUTHN_LOGIN \ 91 | -e CONJUR_AUTHN_TOKEN \ 92 | -e PUBLIC_KEYS \ 93 | -e JWT \ 94 | -e IDENTITY_TOKEN \ 95 | -v "$(pwd)/../output:/conjur-api-go/output" \ 96 | "test-$GO_VERSION" bash -c 'set -xo pipefail; 97 | output_dir="./output/cloud" 98 | go test -coverprofile="$output_dir/c.out" -v ./... | tee "$output_dir/junit.output"; 99 | exit_code=$?; 100 | echo "Tests finished - aggregating results..."; 101 | cat "$output_dir/junit.output" | go-junit-report > "$output_dir/junit.xml"; 102 | gocov convert "$output_dir/c.out" | gocov-xml > "$output_dir/coverage.xml"; 103 | gocovmerge "./output/1.23/c.out" "$output_dir/c.out" > "$output_dir/merged-coverage.out"; 104 | gocov convert "$output_dir/merged-coverage.out" | gocov-xml > "$output_dir/merged-coverage.xml"; 105 | [ "$exit_code" -eq 0 ]' || failed 106 | fi 107 | -------------------------------------------------------------------------------- /bin/utils.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export compose_file="../docker-compose.yml" 4 | 5 | function announce() { 6 | echo " 7 | ================================ 8 | ${1} 9 | ================================ 10 | " 11 | } 12 | 13 | exec_on() { 14 | local container="$1"; shift 15 | 16 | docker exec "$(docker compose ps -q $container)" "$@" 17 | } 18 | 19 | function teardown { 20 | docker compose down -v 21 | docker compose down --remove-orphans 22 | unset API_PKGS 23 | unset API_TESTS 24 | } 25 | 26 | failed() { 27 | announce "TESTS FAILED" 28 | teardown 29 | exit 1 30 | } 31 | 32 | # Docker program name rules: must consist only of lowercase alphanumeric characters, 33 | # hyphens, and underscores as well as start with a letter or number 34 | function project_nameable() { 35 | local split=$(echo "$1" | tr ',.@/' '-') 36 | local lower=$(echo "$split" | tr '[:upper:]' '[:lower:]') 37 | local shrnk=$(echo "$lower" | tr -d 'aeiou') 38 | echo "$shrnk" 39 | } 40 | 41 | # Starts a temporary JWT issuer service and exports the public keys and JWT token 42 | # NOTE: We curl from a container in the compose network so we don't have to map a 43 | # host port - otherwise a port collision may occur when running tests in parallel 44 | function init_jwt_server() { 45 | docker compose up -d jwt-server 46 | export PUBLIC_KEYS=$(docker compose run -T --no-deps --entrypoint /bin/bash conjur -c "curl http://jwt-server:8008/.well-known/jwks.json") 47 | export JWT=$(docker compose run -T --no-deps --entrypoint /bin/bash conjur -c "curl -X POST http://jwt-server:8008/token | jq -r .access_token") 48 | docker compose down jwt-server 49 | } 50 | -------------------------------------------------------------------------------- /conjurapi/authn/api_key_authenticator.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | type APIKeyAuthenticator struct { 4 | Authenticate func(loginPair LoginPair) ([]byte, error) 5 | LoginPair 6 | } 7 | 8 | type LoginPair struct { 9 | Login string 10 | APIKey string 11 | } 12 | 13 | func (a *APIKeyAuthenticator) RefreshToken() ([]byte, error) { 14 | return a.Authenticate(a.LoginPair) 15 | } 16 | 17 | func (a *APIKeyAuthenticator) NeedsTokenRefresh() bool { 18 | return false 19 | } 20 | -------------------------------------------------------------------------------- /conjurapi/authn/api_key_authenticator_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAPIKeyAuthenticator_RefreshToken(t *testing.T) { 11 | var login string 12 | apiKey := "valid-api-key" 13 | authenticate := func(loginPair LoginPair) ([]byte, error) { 14 | if loginPair.Login == "valid-login" && loginPair.APIKey == "valid-api-key" { 15 | return []byte("data"), nil 16 | } else { 17 | return nil, fmt.Errorf("401 Invalid") 18 | } 19 | } 20 | 21 | t.Run("Given valid credentials returns the token bytes", func(t *testing.T) { 22 | // file deepcode ignore NoHardcodedCredentials/test: This is a test file 23 | login := "valid-login" 24 | authenticator := APIKeyAuthenticator{ 25 | Authenticate: authenticate, 26 | LoginPair: LoginPair{ 27 | Login: login, 28 | APIKey: apiKey, 29 | }, 30 | } 31 | 32 | token, err := authenticator.RefreshToken() 33 | 34 | assert.NoError(t, err) 35 | assert.Contains(t, string(token), "data") 36 | }) 37 | 38 | t.Run("Given invalid credentials returns nil with error", func(t *testing.T) { 39 | login = "invalid-login" 40 | authenticator := APIKeyAuthenticator{ 41 | Authenticate: authenticate, 42 | LoginPair: LoginPair{ 43 | Login: login, 44 | APIKey: apiKey, 45 | }, 46 | } 47 | 48 | token, err := authenticator.RefreshToken() 49 | 50 | assert.Nil(t, token) 51 | assert.Error(t, err) 52 | assert.Contains(t, err.Error(), "401") 53 | }) 54 | } 55 | 56 | func TestAPIKeyAuthenticator_NeedsTokenRefresh(t *testing.T) { 57 | t.Run("Returns false", func(t *testing.T) { 58 | authenticator := APIKeyAuthenticator{} 59 | 60 | assert.False(t, authenticator.NeedsTokenRefresh()) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /conjurapi/authn/auth_token.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | const ( 11 | TimeFormatToken4 = "2006-01-02 15:04:05 MST" 12 | ) 13 | 14 | // Sample token 15 | // {"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"eyJzdWIiOiJhZG1pbiIsImlhdCI6MTUxMDc1MzI1OX0=","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"} 16 | // https://www.conjur.org/reference/cryptography.html 17 | type AuthnToken struct { 18 | bytes []byte 19 | Protected string `json:"protected"` 20 | Payload string `json:"payload"` 21 | Signature string `json:"signature"` 22 | iat time.Time 23 | exp *time.Time 24 | } 25 | 26 | func hasField(fields map[string]string, name string) (hasField bool) { 27 | _, hasField = fields[name] 28 | return 29 | } 30 | 31 | func NewToken(data []byte) (token *AuthnToken, err error) { 32 | fields := make(map[string]string) 33 | if err = json.Unmarshal(data, &fields); err != nil { 34 | err = fmt.Errorf("Unable to unmarshal token: %s", err) 35 | return 36 | } 37 | 38 | if hasField(fields, "protected") && hasField(fields, "payload") && hasField(fields, "signature") { 39 | t := &AuthnToken{} 40 | token = t 41 | } else { 42 | err = fmt.Errorf("Unrecognized token format") 43 | return 44 | } 45 | 46 | err = token.FromJSON(data) 47 | 48 | return 49 | } 50 | 51 | func (t *AuthnToken) FromJSON(data []byte) (err error) { 52 | t.bytes = data 53 | 54 | err = json.Unmarshal(data, &t) 55 | if err != nil { 56 | err = fmt.Errorf("Unable to unmarshal access token: %s", err) 57 | return 58 | } 59 | 60 | // Example: {"sub":"admin","iat":1510753259} 61 | payloadFields := make(map[string]interface{}) 62 | var payloadJSON []byte 63 | payloadJSON, err = base64.StdEncoding.DecodeString(t.Payload) 64 | if err != nil { 65 | err = fmt.Errorf("access token field 'payload' is not valid base64") 66 | return 67 | } 68 | err = json.Unmarshal(payloadJSON, &payloadFields) 69 | if err != nil { 70 | err = fmt.Errorf("Unable to unmarshal access token field 'payload': %s", err) 71 | return 72 | } 73 | 74 | iat_v, ok := payloadFields["iat"] 75 | if !ok { 76 | err = fmt.Errorf("access token field 'payload' does not contain 'iat'") 77 | return 78 | } 79 | iat_f := iat_v.(float64) 80 | // In the absence of exp, the token expires at iat+8 minutes 81 | t.iat = time.Unix(int64(iat_f), 0) 82 | 83 | exp_v, ok := payloadFields["exp"] 84 | if ok { 85 | exp_f := exp_v.(float64) 86 | exp := time.Unix(int64(exp_f), 0) 87 | t.exp = &exp 88 | if t.iat.After(*t.exp) { 89 | err = fmt.Errorf("access token expired before it was issued") 90 | return 91 | } 92 | } 93 | 94 | return 95 | } 96 | 97 | func (t *AuthnToken) Raw() []byte { 98 | return t.bytes 99 | } 100 | 101 | func (t *AuthnToken) ShouldRefresh() bool { 102 | if t.exp != nil { 103 | // Expire when the token is 85% expired 104 | lifespan := t.exp.Sub(t.iat) 105 | duration := float32(lifespan) * 0.85 106 | return time.Now().After(t.iat.Add(time.Duration(duration))) 107 | } else { 108 | // Token expires 8 minutes after issue, by default 109 | return time.Now().After(t.iat.Add(5 * time.Minute)) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /conjurapi/authn/auth_token_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestToken_Parse(t *testing.T) { 12 | 13 | token_s := `{"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"eyJzdWIiOiJhZG1pbiIsImlhdCI6MTUxMDc1MzI1OX0=","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"}` 14 | token_with_exp_s := `{"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"eyJzdWIiOiJhZG1pbiIsImlhdCI6MTUxMDc1MzI1OSwiZXhwIjoxNTEwNzUzMzU5fQo=","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"}` 15 | token_mangled_s := `{"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"WIiOiJhZG1","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"}` 16 | token_mangled_2_s := `{"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"Zm9vYmFyCg==","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"}` 17 | 18 | t.Run("Token is parsed successfully", func(t *testing.T) { 19 | token, err := NewToken([]byte(token_s)) 20 | 21 | assert.NoError(t, err) 22 | assert.Equal(t, "*authn.AuthnToken", reflect.TypeOf(token).String()) 23 | assert.NotNil(t, token.Raw()) 24 | }) 25 | 26 | t.Run("Token fields are parsed as expected", func(t *testing.T) { 27 | token, err := NewToken([]byte(token_s)) 28 | assert.NoError(t, err) 29 | 30 | assert.Equal(t, token_s, string(token.Raw())) 31 | 32 | assert.Equal(t, time.Unix(1510753259, 0).String(), token.iat.String()) 33 | assert.Nil(t, token.exp) 34 | 35 | assert.True(t, token.ShouldRefresh()) 36 | }) 37 | 38 | t.Run("Token exp is supported", func(t *testing.T) { 39 | token, err := NewToken([]byte(token_with_exp_s)) 40 | assert.NoError(t, err) 41 | 42 | assert.Equal(t, time.Unix(1510753259, 0).String(), token.iat.String()) 43 | assert.Equal(t, time.Unix(1510753359, 0).String(), token.exp.String()) 44 | 45 | assert.True(t, token.ShouldRefresh()) 46 | }) 47 | 48 | t.Run("Malformed base64 in token is reported", func(t *testing.T) { 49 | _, err := NewToken([]byte(token_mangled_s)) 50 | assert.Equal(t, "access token field 'payload' is not valid base64", err.Error()) 51 | }) 52 | 53 | t.Run("Malformed JSON in token is reported", func(t *testing.T) { 54 | _, err := NewToken([]byte(token_mangled_2_s)) 55 | assert.Equal(t, "Unable to unmarshal access token field 'payload': invalid character 'o' in literal false (expecting 'a')", err.Error()) 56 | }) 57 | 58 | t.Run("Invalid JSON in token is reported", func(t *testing.T) { 59 | token, err := NewToken([]byte("invalid json")) 60 | assert.EqualError(t, err, "Unable to unmarshal token: invalid character 'i' looking for beginning of value") 61 | assert.Nil(t, token) 62 | }) 63 | 64 | t.Run("Token without correct fields", func(t *testing.T) { 65 | token, err := NewToken([]byte(`{"foo":"bar"}`)) 66 | assert.EqualError(t, err, "Unrecognized token format") 67 | assert.Nil(t, token) 68 | }) 69 | 70 | t.Run("Token without iat", func(t *testing.T) { 71 | token_without_iat := `{"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"eyJzdWIiOiJhZG1pbiJ9Cg==","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"}` 72 | _, err := NewToken([]byte(token_without_iat)) 73 | assert.EqualError(t, err, "access token field 'payload' does not contain 'iat'") 74 | }) 75 | 76 | t.Run("Token expired before issued", func(t *testing.T) { 77 | token_exp_before_issued := `{"protected":"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiI5M2VjNTEwODRmZTM3Zjc3M2I1ODhlNTYyYWVjZGMxMSJ9","payload":"eyJzdWIiOiJhZG1pbiIsImlhdCI6MTUxMDc1MzM1OSwiZXhwIjoxNTEwNzUzMjU5fQo=","signature":"raCufKOf7sKzciZInQTphu1mBbLhAdIJM72ChLB4m5wKWxFnNz_7LawQ9iYEI_we1-tdZtTXoopn_T1qoTplR9_Bo3KkpI5Hj3DB7SmBpR3CSRTnnEwkJ0_aJ8bql5Cbst4i4rSftyEmUqX-FDOqJdAztdi9BUJyLfbeKTW9OGg-QJQzPX1ucB7IpvTFCEjMoO8KUxZpbHj-KpwqAMZRooG4ULBkxp5nSfs-LN27JupU58oRgIfaWASaDmA98O2x6o88MFpxK_M0FeFGuDKewNGrRc8lCOtTQ9cULA080M5CSnruCqu1Qd52r72KIOAfyzNIiBCLTkblz2fZyEkdSKQmZ8J3AakxQE2jyHmMT-eXjfsEIzEt-IRPJIirI3Qm"}` 78 | _, err := NewToken([]byte(token_exp_before_issued)) 79 | assert.EqualError(t, err, "access token expired before it was issued") 80 | }) 81 | 82 | t.Run("FromJSON returns error when provided invalid JSON", func(t *testing.T) { 83 | token := &AuthnToken{} 84 | err := token.FromJSON([]byte("invalid json")) 85 | assert.EqualError(t, err, "Unable to unmarshal access token: invalid character 'i' looking for beginning of value") 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /conjurapi/authn/jwt_authenticator.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 8 | ) 9 | 10 | type JWTAuthenticator struct { 11 | JWT string 12 | JWTFilePath string 13 | HostID string 14 | Authenticate func(jwt, hostId string) ([]byte, error) 15 | } 16 | 17 | const k8sJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" 18 | 19 | func (a *JWTAuthenticator) RefreshToken() ([]byte, error) { 20 | err := a.RefreshJWT() 21 | if err != nil { 22 | return nil, fmt.Errorf("Failed to refresh JWT: %v", err) 23 | } 24 | return a.Authenticate(a.JWT, a.HostID) 25 | } 26 | 27 | func (a *JWTAuthenticator) NeedsTokenRefresh() bool { 28 | return false 29 | } 30 | 31 | func (a *JWTAuthenticator) RefreshJWT() error { 32 | // If a JWT token is already set or retrieved, do nothing. 33 | if a.JWT != "" { 34 | logging.ApiLog.Debugf("Using stored JWT") 35 | return nil 36 | } 37 | 38 | // If a token file path is provided, read the JWT token from the file. 39 | // Otherwise, read the token from the default Kubernetes service account path. 40 | var jwtFilePath string 41 | if a.JWTFilePath != "" { 42 | logging.ApiLog.Debugf("Reading JWT from %s", a.JWTFilePath) 43 | jwtFilePath = a.JWTFilePath 44 | } else { 45 | jwtFilePath = k8sJWTPath 46 | logging.ApiLog.Debugf("No JWT file path set. Attempting to ready JWT from %s", jwtFilePath) 47 | } 48 | 49 | token, err := readJWTFromFile(jwtFilePath) 50 | if err != nil { 51 | return err 52 | } 53 | a.JWT = token 54 | return nil 55 | } 56 | 57 | func readJWTFromFile(filePath string) (string, error) { 58 | bytes, err := os.ReadFile(filePath) 59 | if err != nil { 60 | return "", err 61 | } 62 | return string(bytes), nil 63 | } 64 | -------------------------------------------------------------------------------- /conjurapi/authn/jwt_authenticator_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestJWTAuthenticator_RefreshToken(t *testing.T) { 12 | // Test that the RefreshToken method calls the Authenticate method 13 | t.Run("Calls Authenticate with stored JWT", func(t *testing.T) { 14 | authenticator := JWTAuthenticator{ 15 | Authenticate: func(jwt, hostid string) ([]byte, error) { 16 | assert.Equal(t, "jwt", jwt) 17 | assert.Equal(t, "", hostid) 18 | return []byte("token"), nil 19 | }, 20 | JWT: "jwt", 21 | } 22 | 23 | token, err := authenticator.RefreshToken() 24 | 25 | assert.NoError(t, err) 26 | assert.Equal(t, []byte("token"), token) 27 | }) 28 | 29 | t.Run("Calls Authenticate with JWT from file", func(t *testing.T) { 30 | tempDir := t.TempDir() 31 | err := os.WriteFile(filepath.Join(tempDir, "jwt"), []byte("jwt-content"), 0600) 32 | assert.NoError(t, err) 33 | 34 | authenticator := JWTAuthenticator{ 35 | Authenticate: func(jwt, hostid string) ([]byte, error) { 36 | assert.Equal(t, "jwt-content", jwt) 37 | assert.Equal(t, "host-id", hostid) 38 | return []byte("token"), nil 39 | }, 40 | JWTFilePath: filepath.Join(tempDir, "jwt"), 41 | HostID: "host-id", 42 | } 43 | 44 | token, err := authenticator.RefreshToken() 45 | assert.NoError(t, err) 46 | assert.Equal(t, []byte("token"), token) 47 | }) 48 | 49 | t.Run("Defaults to Kubernetes service account path", func(t *testing.T) { 50 | authenticator := JWTAuthenticator{ 51 | Authenticate: func(jwt, hostid string) ([]byte, error) { 52 | assert.Equal(t, "k8s-jwt-content", jwt) 53 | assert.Equal(t, "", hostid) 54 | return []byte("token"), nil 55 | }, 56 | } 57 | 58 | // Note: this may fail when not running in a container 59 | err := os.MkdirAll(filepath.Dir(k8sJWTPath), 0755) 60 | assert.NoError(t, err) 61 | err = os.WriteFile(k8sJWTPath, []byte("k8s-jwt-content"), 0600) 62 | assert.NoError(t, err) 63 | 64 | token, err := authenticator.RefreshToken() 65 | assert.NoError(t, err) 66 | assert.Equal(t, []byte("token"), token) 67 | 68 | t.Cleanup(func() { 69 | os.Remove(k8sJWTPath) 70 | }) 71 | }) 72 | 73 | t.Run("Returns error when Authenticate fails", func(t *testing.T) { 74 | authenticator := JWTAuthenticator{ 75 | Authenticate: func(jwt, hostid string) ([]byte, error) { 76 | return nil, assert.AnError 77 | }, 78 | } 79 | 80 | token, err := authenticator.RefreshToken() 81 | assert.Error(t, err) 82 | assert.Nil(t, token) 83 | }) 84 | 85 | t.Run("Returns error when no JWT provided", func(t *testing.T) { 86 | authenticator := JWTAuthenticator{ 87 | Authenticate: func(jwt, hostid string) ([]byte, error) { 88 | return nil, nil 89 | }, 90 | } 91 | 92 | token, err := authenticator.RefreshToken() 93 | assert.ErrorContains(t, err, "Failed to refresh JWT") 94 | assert.Nil(t, token) 95 | }) 96 | } 97 | 98 | func TestJWTAuthenticator_NeedsTokenRefresh(t *testing.T) { 99 | t.Run("Returns false", func(t *testing.T) { 100 | // Test that the NeedsTokenRefresh method always returns false 101 | authenticator := JWTAuthenticator{} 102 | 103 | assert.False(t, authenticator.NeedsTokenRefresh()) 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /conjurapi/authn/oidc_authenticator.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | type OidcAuthenticator struct { 4 | Code string 5 | Nonce string 6 | CodeVerifier string 7 | Authenticate func(code, nonce, code_verifier string) ([]byte, error) 8 | } 9 | 10 | func (a *OidcAuthenticator) RefreshToken() ([]byte, error) { 11 | return a.Authenticate(a.Code, a.Nonce, a.CodeVerifier) 12 | } 13 | 14 | func (a *OidcAuthenticator) NeedsTokenRefresh() bool { 15 | return false 16 | } 17 | 18 | type OidcTokenAuthenticator struct { 19 | Token string 20 | Authenticate func(token string) ([]byte, error) 21 | } 22 | 23 | func (a *OidcTokenAuthenticator) RefreshToken() ([]byte, error) { 24 | return a.Authenticate(a.Token) 25 | } 26 | 27 | func (a *OidcTokenAuthenticator) NeedsTokenRefresh() bool { 28 | return false 29 | } 30 | -------------------------------------------------------------------------------- /conjurapi/authn/oidc_authenticator_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestOidcAuthenticator_RefreshToken(t *testing.T) { 10 | // Test that the RefreshToken method calls the Authenticate method 11 | t.Run("Calls Authenticate", func(t *testing.T) { 12 | authenticator := OidcAuthenticator{ 13 | Authenticate: func(code, nonce, code_verifier string) ([]byte, error) { 14 | return []byte("token"), nil 15 | }, 16 | } 17 | 18 | token, err := authenticator.RefreshToken() 19 | 20 | assert.NoError(t, err) 21 | assert.Equal(t, []byte("token"), token) 22 | }) 23 | } 24 | 25 | func TestOidcAuthenticator_NeedsTokenRefresh(t *testing.T) { 26 | t.Run("Returns false", func(t *testing.T) { 27 | // Test that the NeedsTokenRefresh method always returns false 28 | authenticator := OidcAuthenticator{} 29 | 30 | assert.False(t, authenticator.NeedsTokenRefresh()) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /conjurapi/authn/token_authenticator.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | type TokenAuthenticator struct { 4 | Token string `env:"CONJUR_AUTHN_TOKEN"` 5 | } 6 | 7 | func (a *TokenAuthenticator) RefreshToken() ([]byte, error) { 8 | return []byte(a.Token), nil 9 | } 10 | 11 | func (a *TokenAuthenticator) NeedsTokenRefresh() bool { 12 | return false 13 | } 14 | -------------------------------------------------------------------------------- /conjurapi/authn/token_authenticator_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestTokenAuthenticator_RefreshToken(t *testing.T) { 10 | // Test that the RefreshToken method returns the token 11 | t.Run("Returns token", func(t *testing.T) { 12 | authenticator := TokenAuthenticator{ 13 | Token: "token", 14 | } 15 | token, err := authenticator.RefreshToken() 16 | assert.NoError(t, err) 17 | assert.Equal(t, []byte("token"), token) 18 | }) 19 | } 20 | 21 | func TestTokenAuthenticator_NeedsTokenRefresh(t *testing.T) { 22 | t.Run("Returns false", func(t *testing.T) { 23 | // Test that the NeedsTokenRefresh method always returns false 24 | authenticator := TokenAuthenticator{ 25 | Token: "token", 26 | } 27 | 28 | assert.False(t, authenticator.NeedsTokenRefresh()) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /conjurapi/authn/token_file_authenticator.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "os" 5 | "time" 6 | ) 7 | 8 | type TokenFileAuthenticator struct { 9 | TokenFile string `env:"CONJUR_AUTHN_TOKEN_FILE"` 10 | mTime time.Time 11 | MaxWaitTime time.Duration 12 | } 13 | 14 | // TODO: is this implementation concurrent ? 15 | func (a *TokenFileAuthenticator) RefreshToken() ([]byte, error) { 16 | maxWaitTime := a.MaxWaitTime 17 | var timeout <-chan time.Time 18 | if maxWaitTime == -1 { 19 | timeout = nil 20 | } else { 21 | timeout = time.After(a.MaxWaitTime) 22 | } 23 | 24 | bytes, err := waitForTextFile(a.TokenFile, timeout) 25 | if err == nil { 26 | fi, _ := os.Stat(a.TokenFile) 27 | a.mTime = fi.ModTime() 28 | } 29 | return bytes, err 30 | } 31 | 32 | func (a *TokenFileAuthenticator) NeedsTokenRefresh() bool { 33 | fi, _ := os.Stat(a.TokenFile) 34 | return a.mTime != fi.ModTime() 35 | } 36 | -------------------------------------------------------------------------------- /conjurapi/authn/token_file_authenticator_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func ensureWriteFile(filepath, filecontents string) { 14 | var prevModTime time.Time 15 | 16 | info, err := os.Stat(filepath) 17 | // Panic for any error that is not! NotExist 18 | if err != nil && !os.IsNotExist(err) { 19 | panic(err) 20 | } 21 | 22 | // Register the previous ModTime, otherwise there is no previous file so fall back to a second before this is 23 | if err != nil { 24 | prevModTime = info.ModTime() 25 | } else { 26 | prevModTime = time.Now().Add(-time.Second) 27 | } 28 | 29 | err = os.WriteFile(filepath, []byte(filecontents), 0600) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | timeout := time.After(10 * time.Second) 35 | ticker := time.NewTicker(10 * time.Millisecond) 36 | for { 37 | select { 38 | // Timeout after 10 seconds. Clearly there's something wrong with i/o 39 | case <-timeout: 40 | err := fmt.Errorf("ensureWriteFile timed out.") 41 | 42 | panic(err) 43 | // Return only if the current ModTime is greater than the previous ModTime 44 | case <-ticker.C: 45 | info, err := os.Stat(filepath) 46 | if err != nil || !info.ModTime().After(prevModTime) { 47 | continue 48 | } 49 | 50 | return 51 | } 52 | } 53 | } 54 | 55 | func TestTokenFileAuthenticator_RefreshToken(t *testing.T) { 56 | t.Run("Retrieve existent token file", func(t *testing.T) { 57 | token_file, _ := os.CreateTemp("", "existent-token-file") 58 | token_file_name := token_file.Name() 59 | defer os.Remove(token_file_name) 60 | 61 | token_file_contents := "token-from-file-contents" 62 | ensureWriteFile(token_file_name, token_file_contents) 63 | 64 | authenticator := TokenFileAuthenticator{ 65 | MaxWaitTime: 1 * time.Second, 66 | TokenFile: token_file_name, 67 | } 68 | 69 | token, err := authenticator.RefreshToken() 70 | 71 | assert.NoError(t, err) 72 | assert.Equal(t, "token-from-file-contents", string(token)) 73 | }) 74 | 75 | t.Run("Retrieve eventually existent token file", func(t *testing.T) { 76 | token_dir := t.TempDir() 77 | token_file_name := path.Join(token_dir, "token") 78 | 79 | token_file_contents := "token-from-file-contents" 80 | go func() { 81 | os.WriteFile(token_file_name, []byte(token_file_contents), 0600) 82 | }() 83 | 84 | authenticator := TokenFileAuthenticator{ 85 | TokenFile: token_file_name, 86 | MaxWaitTime: 10 * time.Second, // The write takes place in a go routine so we need to account for slow i/o 87 | } 88 | 89 | token, err := authenticator.RefreshToken() 90 | 91 | assert.NoError(t, err) 92 | assert.Equal(t, "token-from-file-contents", string(token)) 93 | }) 94 | 95 | t.Run("Times out on never-existent token file", func(t *testing.T) { 96 | token_file := "/path/to/non-existent-token-file" 97 | 98 | authenticator := TokenFileAuthenticator{ 99 | TokenFile: token_file, 100 | MaxWaitTime: 10 * time.Millisecond, // Something non-zero, since zero means immediate failure 101 | } 102 | 103 | token, err := authenticator.RefreshToken() 104 | 105 | assert.Nil(t, token) 106 | assert.Error(t, err) 107 | assert.Equal(t, "Operation waitForTextFile timed out.", err.Error()) 108 | }) 109 | 110 | t.Run("Doesn't time out if MaxWaitTime is -1", func(t *testing.T) { 111 | tempDir := t.TempDir() 112 | token_file_name := path.Join(tempDir, "token") 113 | 114 | go func() { 115 | // Wait some time before writing the file 116 | time.Sleep(500 * time.Millisecond) 117 | token_file_contents := "token-from-file-contents" 118 | os.WriteFile(token_file_name, []byte(token_file_contents), 0600) 119 | }() 120 | 121 | authenticator := TokenFileAuthenticator{ 122 | TokenFile: token_file_name, 123 | MaxWaitTime: -1, // Disable timeout 124 | } 125 | 126 | token, err := authenticator.RefreshToken() 127 | 128 | assert.NoError(t, err) 129 | assert.Equal(t, "token-from-file-contents", string(token)) 130 | }) 131 | } 132 | 133 | func TestTokenFileAuthenticator_NeedsTokenRefresh(t *testing.T) { 134 | t.Run("Token refresh needed after updates", func(t *testing.T) { 135 | token_file, _ := os.CreateTemp("", "existent-token-file") 136 | token_file_name := token_file.Name() 137 | defer os.Remove(token_file_name) 138 | 139 | ensureWriteFile(token_file_name, "token-from-file-contents") 140 | 141 | authenticator := TokenFileAuthenticator{ 142 | TokenFile: token_file_name, 143 | MaxWaitTime: 1 * time.Second, 144 | } 145 | 146 | // Read 147 | _, err := authenticator.RefreshToken() 148 | assert.NoError(t, err) 149 | 150 | // Return false for unmodified file 151 | assert.False(t, authenticator.NeedsTokenRefresh()) 152 | 153 | ensureWriteFile(token_file_name, "recent modification") 154 | 155 | // Return true for modified file 156 | assert.True(t, authenticator.NeedsTokenRefresh()) 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /conjurapi/authn/wait_for_file.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | ) 8 | 9 | func waitForTextFile(fileName string, timeout <-chan time.Time) ([]byte, error) { 10 | var ( 11 | fileBytes []byte 12 | err error 13 | ) 14 | 15 | waiting_loop: 16 | for { 17 | select { 18 | case <-timeout: 19 | err = fmt.Errorf("Operation waitForTextFile timed out.") 20 | break waiting_loop 21 | default: 22 | if _, err := os.Stat(fileName); os.IsNotExist(err) { 23 | time.Sleep(100 * time.Millisecond) 24 | } else { 25 | fileBytes, err = os.ReadFile(fileName) 26 | break waiting_loop 27 | } 28 | } 29 | } 30 | 31 | return fileBytes, err 32 | } 33 | -------------------------------------------------------------------------------- /conjurapi/authn/wait_for_file_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_waitForTextFile(t *testing.T) { 12 | t.Run("Times out for non-existent filename", func(t *testing.T) { 13 | bytes, err := waitForTextFile("path/to/non-existent/file", time.After(0)) 14 | assert.Error(t, err) 15 | assert.Equal(t, err.Error(), "Operation waitForTextFile timed out.") 16 | assert.Nil(t, bytes) 17 | }) 18 | 19 | t.Run("Returns bytes for eventually existent filename", func(t *testing.T) { 20 | file_to_exist, _ := os.CreateTemp("", "existent-file") 21 | file_to_exist_name := file_to_exist.Name() 22 | 23 | os.Remove(file_to_exist_name) 24 | go func() { 25 | os.WriteFile(file_to_exist_name, []byte("some random stuff"), 0600) 26 | }() 27 | defer os.Remove(file_to_exist_name) 28 | 29 | bytes, err := waitForTextFile(file_to_exist_name, nil) 30 | 31 | assert.NoError(t, err) 32 | assert.Equal(t, "some random stuff", string(bytes)) 33 | 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /conjurapi/client.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/cyberark/conjur-api-go/conjurapi/authn" 14 | ) 15 | 16 | type Authenticator interface { 17 | RefreshToken() ([]byte, error) 18 | NeedsTokenRefresh() bool 19 | } 20 | 21 | type CredentialStorageProvider interface { 22 | StoreCredentials(login string, password string) error 23 | ReadCredentials() (login string, password string, err error) 24 | ReadAuthnToken() ([]byte, error) 25 | StoreAuthnToken(token []byte) error 26 | PurgeCredentials() error 27 | } 28 | 29 | type Client struct { 30 | config Config 31 | authToken *authn.AuthnToken 32 | httpClient *http.Client 33 | authenticator Authenticator 34 | storage CredentialStorageProvider 35 | } 36 | 37 | func NewClientFromKey(config Config, loginPair authn.LoginPair) (*Client, error) { 38 | authenticator := &authn.APIKeyAuthenticator{ 39 | LoginPair: loginPair, 40 | } 41 | client, err := newClientWithAuthenticator( 42 | config, 43 | authenticator, 44 | ) 45 | authenticator.Authenticate = client.Authenticate 46 | return client, err 47 | } 48 | 49 | func NewClientFromOidcCode(config Config, code, nonce, code_verifier string) (*Client, error) { 50 | authenticator := &authn.OidcAuthenticator{ 51 | Code: code, 52 | Nonce: nonce, 53 | CodeVerifier: code_verifier, 54 | } 55 | client, err := newClientWithAuthenticator( 56 | config, 57 | authenticator, 58 | ) 59 | if err == nil { 60 | authenticator.Authenticate = client.OidcAuthenticate 61 | } 62 | return client, err 63 | } 64 | 65 | func NewClientFromOidcToken(config Config, token string) (*Client, error) { 66 | authenticator := &authn.OidcTokenAuthenticator{ 67 | Token: token, 68 | } 69 | client, err := newClientWithAuthenticator( 70 | config, 71 | authenticator, 72 | ) 73 | if err == nil { 74 | authenticator.Authenticate = client.OidcTokenAuthenticate 75 | } 76 | return client, err 77 | } 78 | 79 | // ReadResponseBody fully reads a response and closes it. 80 | func ReadResponseBody(response io.ReadCloser) ([]byte, error) { 81 | defer response.Close() 82 | return io.ReadAll(response) 83 | } 84 | 85 | func NewClientFromToken(config Config, token string) (*Client, error) { 86 | return newClientWithAuthenticator( 87 | config, 88 | &authn.TokenAuthenticator{Token: token}, 89 | ) 90 | } 91 | 92 | func NewClientFromTokenFile(config Config, tokenFile string) (*Client, error) { 93 | return newClientWithAuthenticator( 94 | config, 95 | &authn.TokenFileAuthenticator{ 96 | TokenFile: tokenFile, 97 | MaxWaitTime: -1, 98 | }, 99 | ) 100 | } 101 | 102 | func LoginPairFromEnv() (*authn.LoginPair, error) { 103 | return &authn.LoginPair{ 104 | Login: os.Getenv("CONJUR_AUTHN_LOGIN"), 105 | APIKey: os.Getenv("CONJUR_AUTHN_API_KEY"), 106 | }, nil 107 | } 108 | 109 | // TODO: Create a version of this function for creating an authenticator from environment 110 | func NewClientFromEnvironment(config Config) (*Client, error) { 111 | err := config.Validate() 112 | 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | authnTokenFile := os.Getenv("CONJUR_AUTHN_TOKEN_FILE") 118 | if authnTokenFile != "" { 119 | return NewClientFromTokenFile(config, authnTokenFile) 120 | } 121 | 122 | authnToken := os.Getenv("CONJUR_AUTHN_TOKEN") 123 | if authnToken != "" { 124 | return NewClientFromToken(config, authnToken) 125 | } 126 | 127 | if config.JWTFilePath != "" || os.Getenv("CONJUR_AUTHN_JWT_SERVICE_ID") != "" { 128 | return NewClientFromJwt(config) 129 | } 130 | 131 | loginPair, err := LoginPairFromEnv() 132 | if err == nil && loginPair.Login != "" && loginPair.APIKey != "" { 133 | return NewClientFromKey(config, *loginPair) 134 | } 135 | 136 | return newClientFromStoredCredentials(config) 137 | } 138 | 139 | func NewClientFromJwt(config Config) (*Client, error) { 140 | authenticator := &authn.JWTAuthenticator{ 141 | JWT: config.JWTContent, 142 | JWTFilePath: config.JWTFilePath, 143 | HostID: config.JWTHostID, 144 | } 145 | client, err := newClientWithAuthenticator( 146 | config, 147 | authenticator, 148 | ) 149 | if err == nil { 150 | authenticator.Authenticate = client.JWTAuthenticate 151 | } 152 | return client, err 153 | } 154 | 155 | func newClientFromStoredCredentials(config Config) (*Client, error) { 156 | if config.AuthnType == "oidc" { 157 | return newClientFromStoredOidcCredentials(config) 158 | } 159 | 160 | // Attempt to load credentials from whatever storage provider is configured 161 | if storageProvider, _ := createStorageProvider(config); storageProvider != nil { 162 | login, password, err := storageProvider.ReadCredentials() 163 | if err != nil { 164 | return nil, err 165 | } 166 | if login != "" && password != "" { 167 | return NewClientFromKey(config, authn.LoginPair{Login: login, APIKey: password}) 168 | } 169 | } 170 | 171 | return nil, fmt.Errorf("No valid credentials found. Please login again.") 172 | } 173 | 174 | func newClientFromStoredOidcCredentials(config Config) (*Client, error) { 175 | client, err := NewClientFromOidcCode(config, "", "", "") 176 | if err != nil { 177 | return nil, err 178 | } 179 | token := client.readCachedAccessToken() 180 | if token != nil && !token.ShouldRefresh() { 181 | return client, nil 182 | } 183 | return nil, fmt.Errorf("No valid OIDC token found. Please login again.") 184 | } 185 | 186 | func (c *Client) GetAuthenticator() Authenticator { 187 | return c.authenticator 188 | } 189 | 190 | func (c *Client) SetAuthenticator(authenticator Authenticator) { 191 | c.authenticator = authenticator 192 | } 193 | 194 | func (c *Client) GetHttpClient() *http.Client { 195 | return c.httpClient 196 | } 197 | 198 | func (c *Client) SetHttpClient(httpClient *http.Client) { 199 | c.httpClient = httpClient 200 | } 201 | 202 | func (c *Client) GetConfig() Config { 203 | return c.config 204 | } 205 | 206 | func NewClient(config Config) (*Client, error) { 207 | var err error 208 | 209 | err = config.Validate() 210 | 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | httpClient, err := createHttpClient(config) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | storageProvider, err := createStorageProvider(config) 221 | if err != nil { 222 | return nil, err 223 | } 224 | 225 | return &Client{ 226 | config: config, 227 | httpClient: httpClient, 228 | storage: storageProvider, 229 | }, nil 230 | } 231 | 232 | func createHttpClient(config Config) (*http.Client, error) { 233 | var httpClient *http.Client 234 | 235 | if config.IsHttps() { 236 | cert, err := config.ReadSSLCert() 237 | if err != nil { 238 | return nil, err 239 | } 240 | httpClient, err = newHTTPSClient(cert, config) 241 | if err != nil { 242 | return nil, err 243 | } 244 | } else { 245 | httpClient = &http.Client{ 246 | Transport: newHTTPTransport(), 247 | Timeout: time.Second * time.Duration(config.GetHttpTimeout()), 248 | } 249 | } 250 | return httpClient, nil 251 | } 252 | 253 | func newClientWithAuthenticator(config Config, authenticator Authenticator) (*Client, error) { 254 | client, err := NewClient(config) 255 | if err != nil { 256 | return nil, err 257 | } 258 | 259 | client.authenticator = authenticator 260 | return client, nil 261 | } 262 | 263 | func newHTTPSClient(cert []byte, config Config) (*http.Client, error) { 264 | pool := x509.NewCertPool() 265 | ok := pool.AppendCertsFromPEM(cert) 266 | if !ok { 267 | return nil, fmt.Errorf("Can't append Conjur SSL cert") 268 | } 269 | //TODO: Test what happens if this cert is expired 270 | //TODO: What if server cert is rotated 271 | tr := newHTTPTransport() 272 | tr.TLSClientConfig = &tls.Config{RootCAs: pool} 273 | return &http.Client{Transport: tr, Timeout: time.Second * time.Duration(config.GetHttpTimeout())}, nil 274 | } 275 | 276 | func newHTTPTransport() *http.Transport { 277 | return &http.Transport{ 278 | Proxy: http.ProxyFromEnvironment, 279 | DialContext: (&net.Dialer{ 280 | Timeout: time.Second * time.Duration(HTTPDailTimeout), 281 | }).DialContext, 282 | } 283 | } 284 | 285 | // GetTelemetryHeader returns the base64-encoded telemetry header by calling the 286 | // SetFinalTelemetryHeader method from the Config object associated with the Client. 287 | // 288 | // This method delegates the responsibility of constructing and caching the telemetry 289 | // header to the Config's SetFinalTelemetryHeader method and simply returns the result. 290 | // 291 | // Returns: 292 | // - string: The base64-encoded telemetry header. 293 | func (c *Client) GetTelemetryHeader() string { 294 | return c.config.SetFinalTelemetryHeader() 295 | } 296 | -------------------------------------------------------------------------------- /conjurapi/env_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | func splitEq(s string) (string, string) { 9 | a := strings.SplitN(s, "=", 2) 10 | return a[0], a[1] 11 | } 12 | 13 | type envSnapshot struct { 14 | env []string 15 | } 16 | 17 | func ClearEnv() *envSnapshot { 18 | e := os.Environ() 19 | 20 | for _, s := range e { 21 | k, _ := splitEq(s) 22 | os.Setenv(k, "") 23 | } 24 | return &envSnapshot{env: e} 25 | } 26 | 27 | func (e *envSnapshot) RestoreEnv() { 28 | ClearEnv() 29 | for _, s := range e.env { 30 | k, v := splitEq(s) 31 | os.Setenv(k, v) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /conjurapi/host_factory.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/cyberark/conjur-api-go/conjurapi/response" 10 | ) 11 | 12 | type HostFactoryTokenResponse struct { 13 | Expiration string `json:"expiration"` 14 | Cidr []string `json:"cidr"` 15 | Token string `json:"token"` 16 | } 17 | 18 | type HostFactoryHostResponse struct { 19 | CreatedAt string `json:"created_at"` 20 | Id string `json:"id"` 21 | Owner string `json:"owner"` 22 | Permissions []string `json:"permissions"` 23 | Annotations []annotation `json:"annotations"` 24 | RestrictedTo []string `json:"restricted_to"` 25 | ApiKey string `json:"api_key"` 26 | } 27 | 28 | type annotation struct { 29 | Name string `json:"name"` 30 | Value string `json:"value"` 31 | } 32 | 33 | func (c *Client) CreateToken(durationStr string, hostFactory string, cidrs []string, count int) ([]HostFactoryTokenResponse, error) { 34 | 35 | data := url.Values{} 36 | duration, err := time.ParseDuration(durationStr) 37 | if err != nil { 38 | return nil, err 39 | } 40 | expiration := time.Now().Add(duration).Format(time.RFC3339) 41 | account, kind, identifier, err := c.parseIDandEnforceKind(hostFactory, "host_factory") 42 | if err != nil { 43 | return nil, err 44 | } 45 | hostFactory = fmt.Sprintf("%s:%s:%s", account, kind, identifier) 46 | data.Set("host_factory", hostFactory) 47 | data.Set("expiration", expiration) 48 | data.Set("count", fmt.Sprint(count)) 49 | for _, cidr := range cidrs { 50 | data.Add("cidr[]", cidr) 51 | } 52 | return c.createToken(data) 53 | } 54 | 55 | func (c *Client) createToken(data url.Values) ([]HostFactoryTokenResponse, error) { 56 | 57 | encodedData := data.Encode() 58 | 59 | req, err := c.CreateTokenRequest(encodedData) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | resp, err := c.SubmitRequest(req) 65 | if err != nil { 66 | return nil, err 67 | } 68 | respData, err := response.DataResponse(resp) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | var jsonResponse []HostFactoryTokenResponse 74 | err = json.Unmarshal(respData, &jsonResponse) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return jsonResponse, response.EmptyResponse(resp) 79 | } 80 | 81 | func (c *Client) DeleteToken(token string) error { 82 | 83 | req, err := c.DeleteTokenRequest(token) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | resp, err := c.SubmitRequest(req) 89 | if err != nil { 90 | return err 91 | } 92 | return response.EmptyResponse(resp) 93 | } 94 | 95 | func (c *Client) CreateHost(id string, token string) (HostFactoryHostResponse, error) { 96 | return c.CreateHostWithAnnotations(id, token, nil) 97 | } 98 | 99 | // CreateHostWithAnnotations creates a new host given a Host ID, HostFactory token, and a map of annotations 100 | func (c *Client) CreateHostWithAnnotations(id string, token string, annotations map[string]string) (HostFactoryHostResponse, error) { 101 | data := url.Values{} 102 | data.Set("id", id) 103 | for name, val := range annotations { 104 | data.Add(fmt.Sprintf("annotations[%s]", name), val) 105 | } 106 | 107 | return c.createHost(data, token) 108 | } 109 | 110 | func (c *Client) createHost(data url.Values, token string) (HostFactoryHostResponse, error) { 111 | 112 | var jsonResponse HostFactoryHostResponse 113 | encodedData := data.Encode() 114 | req, err := c.CreateHostRequest(encodedData, token) 115 | if err != nil { 116 | return jsonResponse, err 117 | } 118 | 119 | resp, err := c.submitRequestWithCustomAuth(req) 120 | if err != nil { 121 | return jsonResponse, err 122 | } 123 | err = response.JSONResponse(resp, &jsonResponse) 124 | return jsonResponse, err 125 | } 126 | -------------------------------------------------------------------------------- /conjurapi/host_factory_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestClient_Token(t *testing.T) { 11 | config := &Config{} 12 | config.mergeEnv() 13 | 14 | var token string 15 | 16 | testCases := []struct { 17 | name string 18 | duration string 19 | hostFactory string 20 | count int 21 | cidr []string 22 | expectNoToken bool 23 | assert func(*testing.T, error) 24 | assertHost func(*testing.T, int, error) 25 | }{ 26 | { 27 | name: "Create a token", 28 | duration: "10m", 29 | hostFactory: "conjur:host_factory:data/test/factory", 30 | count: 1, 31 | cidr: []string{"0.0.0.0/0"}, 32 | assert: func(t *testing.T, err error) { 33 | assert.NoError(t, err) 34 | }, 35 | assertHost: func(t *testing.T, size int, err error) { 36 | assert.NoError(t, err) 37 | assert.True(t, size > 0) 38 | }, 39 | }, 40 | { 41 | name: "Create a token with a partial hostfactory id", 42 | duration: "10m", 43 | hostFactory: "host_factory:data/test/factory", 44 | count: 1, 45 | cidr: []string{"0.0.0.0/0"}, 46 | assert: func(t *testing.T, err error) { 47 | assert.NoError(t, err) 48 | }, 49 | assertHost: func(t *testing.T, size int, err error) { 50 | assert.NoError(t, err) 51 | assert.True(t, size > 0) 52 | }, 53 | }, 54 | { 55 | name: "Create a token with a partial (singular) hostfactory id", 56 | duration: "10m", 57 | hostFactory: "data/test/factory", 58 | count: 1, 59 | cidr: []string{"0.0.0.0/0"}, 60 | assert: func(t *testing.T, err error) { 61 | assert.NoError(t, err) 62 | }, 63 | assertHost: func(t *testing.T, size int, err error) { 64 | assert.NoError(t, err) 65 | assert.True(t, size > 0) 66 | }, 67 | }, 68 | { 69 | name: "Create a token with two cidrs", 70 | duration: "10m", 71 | hostFactory: "conjur:host_factory:data/test/factory", 72 | count: 1, 73 | cidr: []string{"0.0.0.0/0", "0.0.0.0/32"}, 74 | assert: func(t *testing.T, err error) { 75 | assert.NoError(t, err) 76 | }, 77 | assertHost: func(t *testing.T, size int, err error) { 78 | assert.NoError(t, err) 79 | assert.True(t, size > 0) 80 | }, 81 | }, 82 | { 83 | name: "Create a token with empty cidrs", 84 | duration: "10m", 85 | hostFactory: "conjur:host_factory:data/test/factory", 86 | count: 1, 87 | cidr: []string{}, 88 | assert: func(t *testing.T, err error) { 89 | assert.NoError(t, err) 90 | }, 91 | assertHost: func(t *testing.T, size int, err error) { 92 | assert.NoError(t, err) 93 | assert.True(t, size > 0) 94 | }, 95 | }, 96 | { 97 | name: "Create Two tokens", 98 | duration: "10m", 99 | hostFactory: "conjur:host_factory:data/test/factory", 100 | count: 2, 101 | cidr: []string{"0.0.0.0/0", "0.0.0.0/32"}, 102 | assert: func(t *testing.T, err error) { 103 | assert.NoError(t, err) 104 | }, 105 | assertHost: func(t *testing.T, size int, err error) { 106 | assert.NoError(t, err) 107 | assert.True(t, size > 0) 108 | }, 109 | }, 110 | { 111 | name: "Create a token with invalid cidr", 112 | duration: "10m", 113 | hostFactory: "conjur:host_factory:data/test/factory", 114 | count: 1, 115 | cidr: []string{"127.0.0.1"}, 116 | assert: func(t *testing.T, err error) { 117 | assert.NoError(t, err) 118 | }, 119 | assertHost: func(t *testing.T, size int, err error) { 120 | assert.Error(t, err) 121 | }, 122 | }, 123 | { 124 | name: "Invalid duration", 125 | duration: "10", 126 | hostFactory: "conjur:host_factory:data/test/factory", 127 | count: 1, 128 | cidr: []string{"0.0.0.0/0"}, 129 | expectNoToken: true, 130 | assert: func(t *testing.T, err error) { 131 | assert.Error(t, err) 132 | }, 133 | assertHost: func(t *testing.T, size int, err error) { 134 | return 135 | }, 136 | }, 137 | { 138 | name: "Invalid hostfactory id", 139 | duration: "10m", 140 | hostFactory: "conjur:data/test/factory", 141 | count: 1, 142 | cidr: []string{"0.0.0.0/0"}, 143 | expectNoToken: true, 144 | assert: func(t *testing.T, err error) { 145 | assert.Error(t, err) 146 | }, 147 | assertHost: func(t *testing.T, size int, err error) { 148 | return 149 | }, 150 | }, 151 | } 152 | 153 | t.Run("Host Factory", func(t *testing.T) { 154 | identifier := "factory" 155 | policy := fmt.Sprintf(`- !layer lay 156 | - !host-factory 157 | id: %s 158 | layers: [!layer lay]`, identifier) 159 | 160 | utils, err := NewTestUtils(config) 161 | assert.NoError(t, err) 162 | 163 | utils.Setup(policy) 164 | conjur := utils.Client() 165 | 166 | for _, tc := range testCases { 167 | token = "" 168 | t.Run(tc.name, func(t *testing.T) { 169 | tokens, err := conjur.CreateToken(tc.duration, tc.hostFactory, tc.cidr, tc.count) 170 | tc.assert(t, err) 171 | if err == nil { 172 | assert.Equal(t, len(tokens), tc.count) 173 | for _, tokn := range tokens { 174 | // We just save one token if there are multiple 175 | token = tokn.Token 176 | assert.True(t, len(token) > 0) 177 | } 178 | } 179 | }) 180 | if tc.expectNoToken == true { 181 | continue 182 | } 183 | t.Run("Create Host", func(t *testing.T) { 184 | host, err := conjur.CreateHostWithAnnotations("data/test/new-host", token, map[string]string{"authn/api-key": "true", "creator": "me"}) 185 | tc.assertHost(t, len(host.ApiKey), err) 186 | }) 187 | t.Run("Delete Token", func(t *testing.T) { 188 | err = conjur.DeleteToken(token) 189 | assert.NoError(t, err) 190 | }) 191 | } 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /conjurapi/info.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/cyberark/conjur-api-go/conjurapi/response" 12 | ) 13 | 14 | type EnterpriseInfoResponse struct { 15 | Release string `json:"release"` 16 | Version string `json:"version"` 17 | Services map[string]EnterpriseInfoService `json:"services"` 18 | Container string `json:"container"` 19 | Role string `json:"role"` 20 | Configuration interface{} `json:"configuration"` 21 | Authenticators interface{} `json:"authenticators"` 22 | FipsMode string `json:"fips_mode"` 23 | FeatureFlags interface{} `json:"feature_flags"` 24 | } 25 | 26 | type EnterpriseInfoService struct { 27 | Desired string `json:"desired"` 28 | Status string `json:"status"` 29 | Err string `json:"err"` 30 | Description string `json:"description"` 31 | Name string `json:"name"` 32 | Version string `json:"version"` 33 | Arch string `json:"arch"` 34 | } 35 | 36 | // ServerVersion retrieves the Conjur server version, either from the '/info' endpoint in Conjur Enterprise, 37 | // or from the root endpoint in Conjur OSS. The version returned corresponds to the Conjur OSS version, 38 | // which in Conjur Enterprise is the version of the 'possum' service. 39 | func (c *Client) ServerVersion() (string, error) { 40 | if isConjurCloudURL(c.config.ApplianceURL) { 41 | return "", errors.New("Unable to retrieve server version: not supported in Conjur Cloud") 42 | } 43 | 44 | info, err := c.EnterpriseServerInfo() 45 | if err == nil { 46 | // Return the version of the 'possum' service, which corresponds to the Conjur OSS version 47 | return info.Services["possum"].Version, nil 48 | } 49 | 50 | version, err := c.ServerVersionFromRoot() 51 | if err == nil { 52 | return version, nil 53 | } 54 | 55 | return "", fmt.Errorf("failed to retrieve server version: %s", err) 56 | } 57 | 58 | // EnterpriseServerInfo retrieves the server information from the '/info' endpoint. 59 | // This is only available in Conjur Enterprise and will fail with a 404 error in Conjur OSS. 60 | func (c *Client) EnterpriseServerInfo() (*EnterpriseInfoResponse, error) { 61 | if isConjurCloudURL(c.config.ApplianceURL) { 62 | return nil, errors.New("Unable to retrieve server info: not supported in Conjur Cloud") 63 | } 64 | 65 | req, err := c.ServerInfoRequest() 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | resp, err := c.httpClient.Do(req) 71 | // Handle 404 or 401 response, which indicates that the '/info' endpoint is not available (eg. in Conjur OSS) 72 | if resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized) { 73 | return nil, fmt.Errorf("404 Not Found: Are you using Conjur Enterprise?") 74 | } 75 | 76 | // Handle any other errors 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to retrieve server info: %s", err) 79 | } 80 | 81 | infoResponse := EnterpriseInfoResponse{} 82 | return &infoResponse, response.JSONResponse(resp, &infoResponse) 83 | } 84 | 85 | // ServerVersionFromRoot retrieves the server version from the root endpoint. 86 | // This is a fallback method in case the '/info' endpoint is not available (such as in Conjur OSS). 87 | // In older versions of Conjur, the version was only available in an HTML response, and 88 | // this method will parse it from there. 89 | // In newer Conjur versions, the version is available in a JSON response. 90 | func (c *Client) ServerVersionFromRoot() (string, error) { 91 | if isConjurCloudURL(c.config.ApplianceURL) { 92 | return "", errors.New("Unable to retrieve server version: not supported in Conjur Cloud") 93 | } 94 | 95 | req, err := c.RootRequest() 96 | if err != nil { 97 | return "", err 98 | } 99 | 100 | resp, err := c.httpClient.Do(req) 101 | if err != nil { 102 | return "", err 103 | } 104 | 105 | body, err := response.DataResponse(resp) 106 | if err != nil { 107 | return "", err 108 | } 109 | 110 | serverVersion, err := parseVersionFromRoot(resp, body) 111 | if err != nil { 112 | return "", err 113 | } 114 | 115 | return serverVersion, nil 116 | } 117 | 118 | func parseVersionFromRoot(rootResponse *http.Response, body []byte) (string, error) { 119 | if strings.Contains(rootResponse.Header.Get("content-type"), "application/json") { 120 | return parseVersionFromJSON(body) 121 | } 122 | 123 | return parseVersionFromHTML(string(body)) 124 | } 125 | 126 | func parseVersionFromJSON(jsonContent []byte) (string, error) { 127 | // Parse the body as JSON and look for the version field 128 | var result map[string]interface{} 129 | if err := json.Unmarshal(jsonContent, &result); err != nil { 130 | return "", fmt.Errorf("failed to parse JSON: %s", err) 131 | } 132 | 133 | if version, ok := result["version"].(string); ok { 134 | return version, nil 135 | } 136 | 137 | return "", fmt.Errorf("version field not found") 138 | } 139 | 140 | func parseVersionFromHTML(htmlContent string) (string, error) { 141 | // Parse the body as HTML and look for the version field 142 | // It should look like this: 143 | //
Version 1.21.0.1-25
144 | re := regexp.MustCompile(`
\s*Version\s*([^\s<]+)\s*<\/dd>`) 145 | matches := re.FindStringSubmatch(htmlContent) 146 | // This will return an slice with two elements: The first is the full HTML tag (e.g. "
Version...") 147 | // and the second is just the version number (the capture group in the regex) 148 | if len(matches) < 2 { 149 | return "", fmt.Errorf("version field not found") 150 | } 151 | // Return just the version number 152 | return matches[1], nil 153 | } 154 | -------------------------------------------------------------------------------- /conjurapi/info_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // Version should be a number but can also contain dots and dashes (eg. 1.21.0.1-25). 13 | // It can also contain trailing characters (eg. 0.0.dev). 14 | var versionRegex = regexp.MustCompile(`^[\d.-]+`) 15 | 16 | func TestServerVersion(t *testing.T) { 17 | if isConjurCloudURL(os.Getenv("CONJUR_APPLIANCE_URL")) { 18 | t.Run("Server version not supported on Conjur Cloud", func(t *testing.T) { 19 | utils, err := NewTestUtils(&Config{}) 20 | assert.NoError(t, err) 21 | conjur := utils.Client() 22 | 23 | version, err := conjur.ServerVersion() 24 | require.Error(t, err) 25 | assert.ErrorContains(t, err, "not supported in Conjur Cloud") 26 | assert.Empty(t, version) 27 | }) 28 | return 29 | } 30 | 31 | t.Run("Gets server version", func(t *testing.T) { 32 | utils, err := NewTestUtils(&Config{}) 33 | assert.NoError(t, err) 34 | conjur := utils.Client() 35 | 36 | version, err := conjur.ServerVersion() 37 | require.NoError(t, err) 38 | 39 | assert.NotEmpty(t, version) 40 | assert.Regexp(t, versionRegex, version) 41 | }) 42 | 43 | t.Run("Enterprise (Mocked): Gets server version", func(t *testing.T) { 44 | mockServer, mockClient := createMockConjurClient(t) 45 | defer mockServer.Close() 46 | version, err := mockClient.ServerVersion() 47 | require.NoError(t, err) 48 | 49 | assert.NotEmpty(t, version) 50 | assert.Regexp(t, versionRegex, version) 51 | }) 52 | 53 | t.Run("Mocked: Fails to get server version", func(t *testing.T) { 54 | // Store the original mocked values 55 | originalMockEnterpriseInfo := mockEnterpriseInfo 56 | originalMockRootResponse := mockRootResponse 57 | 58 | // Set the mock values 59 | mockEnterpriseInfo = "" 60 | mockRootResponse = "" 61 | 62 | // Restore the original mocked values 63 | defer func() { 64 | mockEnterpriseInfo = originalMockEnterpriseInfo 65 | mockRootResponse = originalMockRootResponse 66 | }() 67 | 68 | mockServer, mockClient := createMockConjurClient(t) 69 | defer mockServer.Close() 70 | version, err := mockClient.ServerVersion() 71 | require.Error(t, err) 72 | assert.ErrorContains(t, err, "failed to retrieve server version") 73 | assert.Empty(t, version) 74 | }) 75 | } 76 | 77 | func TestEnterpriseServerInfo(t *testing.T) { 78 | if isConjurCloudURL(os.Getenv("CONJUR_APPLIANCE_URL")) { 79 | t.Run("Server version not supported on Conjur Cloud", func(t *testing.T) { 80 | utils, err := NewTestUtils(&Config{}) 81 | assert.NoError(t, err) 82 | conjur := utils.Client() 83 | 84 | info, err := conjur.EnterpriseServerInfo() 85 | require.Error(t, err) 86 | assert.ErrorContains(t, err, "not supported in Conjur Cloud") 87 | assert.Nil(t, info) 88 | }) 89 | return 90 | } 91 | 92 | t.Run("Enterprise (Mocked): Gets server info from the '/info' endpoint", func(t *testing.T) { 93 | mockServer, mockClient := createMockConjurClient(t) 94 | defer mockServer.Close() 95 | info, err := mockClient.EnterpriseServerInfo() 96 | require.NoError(t, err) 97 | 98 | assert.NotEmpty(t, info.Version) 99 | assert.NotEmpty(t, info.Release) 100 | assert.NotEmpty(t, info.Role) 101 | assert.Regexp(t, versionRegex, info.Version) 102 | assert.Contains(t, info.Services, "ui") 103 | assert.Contains(t, info.Services, "possum") 104 | assert.Regexp(t, versionRegex, info.Services["possum"].Version) 105 | }) 106 | 107 | t.Run("OSS: Fails to get server info from the '/info' endpoint", func(t *testing.T) { 108 | // Use a real Conjur client to test the '/info' endpoint. 109 | // TODO: Skip this test on Enterprise 110 | utils, err := NewTestUtils(&Config{}) 111 | assert.NoError(t, err) 112 | conjur := utils.Client() 113 | 114 | info, err := conjur.EnterpriseServerInfo() 115 | require.Error(t, err) 116 | assert.ErrorContains(t, err, "404") 117 | assert.Nil(t, info) 118 | }) 119 | } 120 | 121 | func TestServerVersionFromRoot(t *testing.T) { 122 | if isConjurCloudURL(os.Getenv("CONJUR_APPLIANCE_URL")) { 123 | t.Run("Server version not supported on Conjur Cloud", func(t *testing.T) { 124 | utils, err := NewTestUtils(&Config{}) 125 | assert.NoError(t, err) 126 | conjur := utils.Client() 127 | 128 | version, err := conjur.ServerVersionFromRoot() 129 | require.Error(t, err) 130 | assert.ErrorContains(t, err, "not supported in Conjur Cloud") 131 | assert.Empty(t, version) 132 | }) 133 | // Skip the rest of the tests when running against Conjur Cloud 134 | return 135 | } 136 | 137 | t.Run("Gets server version from the root endpoint", func(t *testing.T) { 138 | utils, err := NewTestUtils(&Config{}) 139 | assert.NoError(t, err) 140 | conjur := utils.Client() 141 | 142 | version, err := conjur.ServerVersionFromRoot() 143 | require.NoError(t, err) 144 | 145 | assert.NotEmpty(t, version) 146 | assert.Regexp(t, versionRegex, version) 147 | }) 148 | 149 | mockedTestCases := []struct { 150 | name string 151 | rootResponse string 152 | contentType string 153 | expectErrorContains string 154 | }{ 155 | { 156 | name: "HTML Response", 157 | rootResponse: mockRootResponseHTML, 158 | contentType: "text/html", 159 | }, 160 | { 161 | name: "JSON Response", 162 | rootResponse: mockRootResponseJSON, 163 | contentType: "application/json", 164 | }, 165 | { 166 | name: "Empty Response", 167 | rootResponse: "", 168 | contentType: "application/json", 169 | expectErrorContains: "failed to parse JSON", 170 | }, 171 | { 172 | name: "Invalid JSON Response", 173 | rootResponse: "Invalid response", 174 | contentType: "application/json", 175 | expectErrorContains: "failed to parse JSON", 176 | }, 177 | { 178 | name: "JSON Missing Version Field", 179 | rootResponse: `{"not_version": "1.0.0"}`, 180 | contentType: "application/json", 181 | expectErrorContains: "version field not found", 182 | }, 183 | { 184 | name: "Invalid HTML Response", 185 | rootResponse: "Invalid response", 186 | contentType: "text/html", 187 | expectErrorContains: "version field not found", 188 | }, 189 | } 190 | 191 | // Store the original mocked values 192 | originalMockRootResponse := mockRootResponse 193 | originalMockRootResponseContentType := mockRootResponseContentType 194 | 195 | t.Cleanup(func() { 196 | // Restore the original mocked values 197 | mockRootResponse = originalMockRootResponse 198 | mockRootResponseContentType = originalMockRootResponseContentType 199 | }) 200 | 201 | for _, tc := range mockedTestCases { 202 | t.Run("Mocked: "+tc.name, func(t *testing.T) { 203 | mockRootResponse = tc.rootResponse 204 | mockRootResponseContentType = tc.contentType 205 | mockServer, mockClient := createMockConjurClient(t) 206 | defer mockServer.Close() 207 | version, err := mockClient.ServerVersionFromRoot() 208 | 209 | if tc.expectErrorContains != "" { 210 | require.Error(t, err) 211 | assert.ErrorContains(t, err, tc.expectErrorContains) 212 | assert.Empty(t, version) 213 | return 214 | } 215 | 216 | require.NoError(t, err) 217 | 218 | assert.NotEmpty(t, version) 219 | assert.Regexp(t, versionRegex, version) 220 | }) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /conjurapi/issuer.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/cyberark/conjur-api-go/conjurapi/response" 10 | ) 11 | 12 | // Issuer defines the JSON data structure used with the Conjur API 13 | type Issuer struct { 14 | ID string `json:"id"` 15 | Type string `json:"type"` 16 | MaxTTL int `json:"max_ttl"` 17 | Data map[string]interface{} `json:"data"` 18 | 19 | // Metadata fields returned by the Conjur API 20 | CreatedAt string `json:"created_at,omitempty"` 21 | ModifiedAt string `json:"modified_at,omitempty"` 22 | } 23 | 24 | // IssuerUpdate defines the specific fields allowed in an Issuer update 25 | // request. 26 | type IssuerUpdate struct { 27 | MaxTTL *int `json:"max_ttl,omitempty"` 28 | Data map[string]interface{} `json:"data,omitempty"` 29 | } 30 | 31 | // IssuerList defines the JSON structure returned by the issuer list endpoint 32 | // in the Conjur API 33 | type IssuerList struct { 34 | Issuers []Issuer `json:"issuers"` 35 | } 36 | 37 | // CreateIssuer creates a new Issuer in Conjur 38 | func (c *Client) CreateIssuer(issuer Issuer) (created Issuer, err error) { 39 | req, err := c.createIssuerRequest(issuer) 40 | if err != nil { 41 | return 42 | } 43 | 44 | resp, err := c.SubmitRequest(req) 45 | if err != nil { 46 | return 47 | } 48 | 49 | data, err := response.DataResponse(resp) 50 | if err != nil { 51 | return 52 | } 53 | 54 | err = json.Unmarshal(data, &created) 55 | return 56 | } 57 | 58 | // DeleteIssuer deletes an existing Issuer in Conjur 59 | func (c *Client) DeleteIssuer(issuerID string, keepSecrets bool) (err error) { 60 | req, err := c.deleteIssuerRequest(issuerID, keepSecrets) 61 | if err != nil { 62 | return 63 | } 64 | 65 | resp, err := c.SubmitRequest(req) 66 | if err != nil { 67 | return 68 | } 69 | 70 | err = response.EmptyResponse(resp) 71 | return 72 | } 73 | 74 | // Issuer retrieves an existing Issuer with the given ID 75 | func (c *Client) Issuer(issuerID string) (issuer Issuer, err error) { 76 | req, err := c.issuerRequest(issuerID) 77 | if err != nil { 78 | return 79 | } 80 | 81 | resp, err := c.SubmitRequest(req) 82 | if err != nil { 83 | return 84 | } 85 | 86 | data, err := response.DataResponse(resp) 87 | if err != nil { 88 | return 89 | } 90 | 91 | err = json.Unmarshal(data, &issuer) 92 | return 93 | } 94 | 95 | // Issuers returns the collection of Issuers the caller is permitted to view 96 | func (c *Client) Issuers() (issuers []Issuer, err error) { 97 | req, err := c.issuersRequest() 98 | if err != nil { 99 | return 100 | } 101 | 102 | resp, err := c.SubmitRequest(req) 103 | if err != nil { 104 | return 105 | } 106 | 107 | data, err := response.DataResponse(resp) 108 | if err != nil { 109 | return 110 | } 111 | 112 | issuerList := IssuerList{} 113 | err = json.Unmarshal(data, &issuerList) 114 | if err != nil { 115 | return 116 | } 117 | 118 | issuers = issuerList.Issuers 119 | return 120 | } 121 | 122 | // UpdateIssuer modifies the TTL and/or data on an existing Issuer 123 | func (c *Client) UpdateIssuer(issuerID string, issuerUpdate IssuerUpdate) (updated Issuer, err error) { 124 | req, err := c.updateIssuerRequest(issuerID, issuerUpdate) 125 | if err != nil { 126 | return 127 | } 128 | 129 | resp, err := c.SubmitRequest(req) 130 | if err != nil { 131 | return 132 | } 133 | 134 | data, err := response.DataResponse(resp) 135 | if err != nil { 136 | return 137 | } 138 | 139 | err = json.Unmarshal(data, &updated) 140 | return 141 | } 142 | 143 | func (c *Client) createIssuerRequest(issuer Issuer) (*http.Request, error) { 144 | issuersURL := makeRouterURL(c.issuersURL(c.config.Account)) 145 | 146 | issuerJSON, err := json.Marshal(issuer) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | req, err := http.NewRequest( 152 | "POST", 153 | issuersURL.String(), 154 | bytes.NewReader(issuerJSON), 155 | ) 156 | if err != nil { 157 | return nil, err 158 | } 159 | req.Header.Add(ConjurSourceHeader, c.GetTelemetryHeader()) 160 | req.Header.Set("Content-Type", "application/json") 161 | 162 | return req, nil 163 | } 164 | 165 | func (c *Client) deleteIssuerRequest(issuerID string, keepSecrets bool) (*http.Request, error) { 166 | issuerURL := makeRouterURL( 167 | c.issuersURL(c.config.Account), 168 | url.QueryEscape(issuerID), 169 | ).withFormattedQuery("keep_secrets=%t", keepSecrets) 170 | 171 | req, err := http.NewRequest("DELETE", issuerURL.String(), nil) 172 | if err != nil { 173 | return nil, err 174 | } 175 | req.Header.Add(ConjurSourceHeader, c.GetTelemetryHeader()) 176 | 177 | return req, nil 178 | } 179 | 180 | func (c *Client) issuerRequest(issuerID string) (*http.Request, error) { 181 | issuerURL := makeRouterURL( 182 | c.issuersURL(c.config.Account), 183 | url.QueryEscape(issuerID), 184 | ) 185 | 186 | req, err := http.NewRequest("GET", issuerURL.String(), nil) 187 | if err != nil { 188 | return nil, err 189 | } 190 | req.Header.Add(ConjurSourceHeader, c.GetTelemetryHeader()) 191 | 192 | return req, nil 193 | } 194 | 195 | func (c *Client) issuersRequest() (*http.Request, error) { 196 | issuerURL := makeRouterURL(c.issuersURL(c.config.Account)) 197 | 198 | req, err := http.NewRequest("GET", issuerURL.String(), nil) 199 | if err != nil { 200 | return nil, err 201 | } 202 | req.Header.Add(ConjurSourceHeader, c.GetTelemetryHeader()) 203 | 204 | return req, nil 205 | } 206 | 207 | func (c *Client) updateIssuerRequest(issuerID string, issuerUpdate IssuerUpdate) (*http.Request, error) { 208 | issuerURL := makeRouterURL( 209 | c.issuersURL(c.config.Account), 210 | url.QueryEscape(issuerID), 211 | ) 212 | 213 | issuerUpdateJSON, err := json.Marshal(issuerUpdate) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | req, err := http.NewRequest( 219 | "PATCH", 220 | issuerURL.String(), 221 | bytes.NewReader(issuerUpdateJSON), 222 | ) 223 | if err != nil { 224 | return nil, err 225 | } 226 | req.Header.Add(ConjurSourceHeader, c.GetTelemetryHeader()) 227 | req.Header.Set("Content-Type", "application/json") 228 | 229 | return req, nil 230 | } 231 | 232 | func (c *Client) issuersURL(account string) string { 233 | return makeRouterURL(c.config.ApplianceURL, "issuers", account).String() 234 | } 235 | -------------------------------------------------------------------------------- /conjurapi/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // ApiLog is a separate logrus logger for the API. The destination for 11 | // its messages is controlled by the environment variable 12 | // CONJURAPI_LOG. CONJRAPI_LOG can be "stdout", "stderr", or the path 13 | // to a file. If it's a path, the file's contents will be overwritten 14 | // with new messages. If the environment variable is not set, logging 15 | // is disabled. 16 | var ApiLog = logrus.New() 17 | var fatalFn = logrus.Fatalf 18 | 19 | func init() { 20 | initLogger() 21 | } 22 | 23 | func initLogger() { 24 | dest, ok := os.LookupEnv("CONJURAPI_LOG") 25 | if !ok { 26 | return 27 | } 28 | 29 | var ( 30 | out io.Writer 31 | err error 32 | ) 33 | switch dest { 34 | case "stdout": 35 | out = os.Stdout 36 | case "stderr": 37 | out = os.Stderr 38 | default: 39 | out, err = os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0644) 40 | if err != nil { 41 | fatalFn("Failed to open %s: %v", dest, err.Error()) 42 | } 43 | logrus.Infof("Logging to %s", dest) 44 | } 45 | 46 | ApiLog.Out = out 47 | ApiLog.Level = logrus.DebugLevel 48 | } 49 | -------------------------------------------------------------------------------- /conjurapi/logging/logging_test.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestInitLogger(t *testing.T) { 14 | t.Run("CONJURAPI_LOG is not set", func(t *testing.T) { 15 | os.Unsetenv("CONJURAPI_LOG") 16 | initLogger() 17 | // Defaults to Stderr with Info level 18 | assert.Equal(t, os.Stderr, ApiLog.Out) 19 | assert.Equal(t, logrus.InfoLevel, ApiLog.Level) 20 | }) 21 | 22 | t.Run("stdout", func(t *testing.T) { 23 | os.Setenv("CONJURAPI_LOG", "stdout") 24 | initLogger() 25 | assert.Equal(t, os.Stdout, ApiLog.Out) 26 | assert.Equal(t, logrus.DebugLevel, ApiLog.Level) 27 | }) 28 | 29 | t.Run("stderr", func(t *testing.T) { 30 | os.Setenv("CONJURAPI_LOG", "stderr") 31 | initLogger() 32 | assert.Equal(t, os.Stderr, ApiLog.Out) 33 | assert.Equal(t, logrus.DebugLevel, ApiLog.Level) 34 | }) 35 | 36 | t.Run("file", func(t *testing.T) { 37 | tmpFile := t.TempDir() + "/logfile.log" 38 | os.Setenv("CONJURAPI_LOG", tmpFile) 39 | initLogger() 40 | assertFileExists(t, tmpFile) 41 | assert.Equal(t, logrus.DebugLevel, ApiLog.Level) 42 | }) 43 | 44 | t.Run("file in nonexistent directory", func(t *testing.T) { 45 | tmpFile := "/nonexistent/logfile.log" 46 | fatalCalled := false 47 | 48 | // Mock the logrus.Fatalf function 49 | fatalFn = func(format string, args ...interface{}) { 50 | fatalCalled = true 51 | assert.Contains(t, format, "Failed to open") 52 | assert.Len(t, args, 2) 53 | assert.Equal(t, args[0], tmpFile) 54 | assert.Contains(t, args[1], "no such file or directory") 55 | } 56 | 57 | os.Setenv("CONJURAPI_LOG", tmpFile) 58 | initLogger() 59 | assert.True(t, fatalCalled) 60 | }) 61 | } 62 | 63 | func assertFileExists(t *testing.T, filePath string) { 64 | _, err := os.Stat(filePath) 65 | assert.False(t, os.IsNotExist(err), "Expected file to exist: %s", filePath) 66 | } 67 | 68 | func TestApiLog(t *testing.T) { 69 | // Redirect logrus output to a buffer 70 | var buf bytes.Buffer 71 | ApiLog = logrus.New() 72 | ApiLog.Out = &buf 73 | ApiLog.Level = logrus.DebugLevel 74 | 75 | // Test logging 76 | ApiLog.Debug("Debug message") 77 | ApiLog.Info("Info message") 78 | ApiLog.Warn("Warning message") 79 | ApiLog.Error("Error message") 80 | 81 | // Read the buffer contents 82 | logOutput, err := io.ReadAll(&buf) 83 | assert.NoError(t, err) 84 | 85 | assert.Contains(t, string(logOutput), "Debug message") 86 | assert.Contains(t, string(logOutput), "Info message") 87 | assert.Contains(t, string(logOutput), "Warning message") 88 | assert.Contains(t, string(logOutput), "Error message") 89 | } 90 | -------------------------------------------------------------------------------- /conjurapi/mock_conjur_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | // Creates a Conjur client that points towards a mock Conjur server. 13 | // The server will return test values for the login, authenticate, and OIDC provider endpoints, 14 | // as well as for the /info and / endpoints. 15 | // TODO: Use actual Conjur instance instead of mock server? 16 | func createMockConjurClient(t *testing.T) (*httptest.Server, *Client) { 17 | mockConjurServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | // Listen for the login, authenticate, and oidc endpoints and return test values 19 | if strings.HasSuffix(r.URL.Path, "/authn/conjur/login") { 20 | w.WriteHeader(http.StatusOK) 21 | w.Write([]byte("test-api-key")) 22 | } else if strings.HasSuffix(r.URL.Path, "/authn/conjur/alice/authenticate") { 23 | // Ensure that the api key we returned in /login is being used 24 | body, _ := io.ReadAll(r.Body) 25 | if string(body) == "test-api-key" { 26 | w.WriteHeader(http.StatusOK) 27 | w.Write([]byte("test-token")) 28 | } else { 29 | w.WriteHeader(http.StatusUnauthorized) 30 | } 31 | } else if strings.HasSuffix(r.URL.Path, "/authn-oidc/test-service-id/conjur/authenticate") { 32 | w.WriteHeader(http.StatusOK) 33 | w.Write([]byte("test-token-oidc")) 34 | } else if strings.HasSuffix(r.URL.Path, "/authn-jwt/test-service-id/conjur/authenticate") { 35 | w.WriteHeader(http.StatusOK) 36 | w.Write([]byte("test-token-jwt")) 37 | } else if strings.HasSuffix(r.URL.Path, "/authn-oidc/conjur/providers") { 38 | w.WriteHeader(http.StatusOK) 39 | w.Write([]byte(`[{"service_id": "test-service-id"}]`)) 40 | } else if r.URL.Path == "/info" { 41 | if mockEnterpriseInfo == "" { 42 | w.WriteHeader(http.StatusNotFound) 43 | return 44 | } 45 | w.WriteHeader(http.StatusOK) 46 | w.Write([]byte(mockEnterpriseInfo)) 47 | } else if r.URL.Path == "/" { 48 | w.Header().Set("Content-Type", mockRootResponseContentType) 49 | w.WriteHeader(http.StatusOK) 50 | w.Write([]byte(mockRootResponse)) 51 | } else { 52 | w.WriteHeader(http.StatusNotFound) 53 | } 54 | })) 55 | 56 | tempDir := t.TempDir() 57 | config := Config{ 58 | Account: "conjur", 59 | ApplianceURL: mockConjurServer.URL, 60 | NetRCPath: filepath.Join(tempDir, ".netrc"), 61 | CredentialStorage: "file", 62 | } 63 | storage, _ := createStorageProvider(config) 64 | client := &Client{ 65 | config: config, 66 | httpClient: &http.Client{}, 67 | storage: storage, 68 | } 69 | 70 | return mockConjurServer, client 71 | } 72 | 73 | var mockEnterpriseInfo = `{ 74 | "release": "13.5.0", 75 | "version": "5.19.0-9", 76 | "services": { 77 | "ldap-sync": { 78 | "desired": "i", 79 | "status": "i", 80 | "err": null, 81 | "description": "Conjur", 82 | "name": "conjur-ldap-sync", 83 | "version": "2.4.9-452", 84 | "arch": "amd64" 85 | }, 86 | "possum": { 87 | "desired": "i", 88 | "status": "i", 89 | "err": null, 90 | "description": "Conjur", 91 | "name": "conjur-possum", 92 | "version": "1.21.3-11", 93 | "arch": "amd64" 94 | }, 95 | "ui": { 96 | "desired": "i", 97 | "status": "i", 98 | "err": null, 99 | "description": "Conjur", 100 | "name": "conjur-ui", 101 | "version": "2.18.0-512", 102 | "arch": "amd64" 103 | } 104 | }, 105 | "container": "conjur-leader-1.mycompany.local", 106 | "role": "master", 107 | "configuration": { 108 | "conjur": { 109 | "account": "conjur", 110 | "altnames": [ 111 | "AMPM-42529A0948.ampm.cyberng.com", 112 | "localhost", 113 | "conjur-leader.mycompany.local", 114 | "conjur-leader-1.mycompany.local", 115 | "conjur-leader-2.mycompany.local", 116 | "conjur-leader-3.mycompany.local", 117 | "AMPM-42529A0948.ampm.cyberng.com" 118 | ], 119 | "hostname": "AMPM-42529A0948.ampm.cyberng.com", 120 | "master_altnames": [ 121 | "AMPM-42529A0948.ampm.cyberng.com", 122 | "localhost", 123 | "conjur-leader.mycompany.local", 124 | "conjur-leader-1.mycompany.local", 125 | "conjur-leader-2.mycompany.local", 126 | "conjur-leader-3.mycompany.local", 127 | "AMPM-42529A0948.ampm.cyberng.com" 128 | ], 129 | "role": "master" 130 | } 131 | }, 132 | "authenticators": { 133 | "error": "Conjur service not available." 134 | }, 135 | "fips_mode": "enabled", 136 | "feature_flags": { 137 | "selective_replication": "enabled" 138 | } 139 | }` 140 | 141 | var mockRootResponseHTML = ` 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | Conjur Status 150 | 151 | 152 | 153 |
154 |
155 | 156 |
157 | 162 |
163 | 164 |
165 |
166 |

Status

167 |

Your Conjur server is running!

168 | 169 |

Security Check:

170 |

Does your browser show a green lock icon on the left side of the address bar?

171 | 172 |
173 |
Green lock:
174 |
Good, Conjur is secured and authenticated.
175 |
Yellow lock or green with warning sign:
176 |
177 | OK, Conjur is secured but not authenticated. Send your Conjur admin to the 178 | 179 | Conjur+TLS guide 180 | 181 | to learn how to use your own certificate & upgrade to green lock. 182 |
183 |
Red broken lock or no lock:
184 |
185 | Conjur is running in insecure development mode. Don't put any 186 | production secrets in there! Visit the 187 | 188 | Conjur+TLS guide 189 | 190 | to learn how to deploy Conjur securely & 191 | contact CyberArk 192 | with any questions. 193 |
194 |
195 |
196 | 197 |
198 |
199 |
Details:
200 |
Version 0.0.dev
201 |
API Version 5.3.1 203 | 204 |
FIPS mode enabled 205 |
More Info:
206 |
207 | 212 |
213 |
214 | 215 |
216 |
217 | 218 |
219 |
220 | 221 |
222 | 225 |
226 | 227 | 228 | ` 229 | 230 | var mockRootResponseJSON = `{"version": "0.0.dev"}` 231 | 232 | var mockRootResponse = mockRootResponseHTML 233 | var mockRootResponseContentType = "text/html" 234 | -------------------------------------------------------------------------------- /conjurapi/policy.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/cyberark/conjur-api-go/conjurapi/response" 9 | ) 10 | 11 | // PolicyMode defines the server-sized behavior when loading a policy. 12 | type PolicyMode uint 13 | 14 | const ( 15 | // PolicyModePost appends new data to the policy. 16 | PolicyModePost PolicyMode = 1 17 | // PolicyModePut completely replaces the policy, implicitly deleting data which is not present in the new policy. 18 | PolicyModePut PolicyMode = 2 19 | // PolicyModePatch adds policy data and explicitly deletes policy data. 20 | PolicyModePatch PolicyMode = 3 21 | ) 22 | 23 | // CreatedRole contains the full role ID and API key of a role which was created 24 | // by the server when loading a policy. 25 | type CreatedRole struct { 26 | ID string `json:"id"` 27 | APIKey string `json:"api_key,omitempty"` 28 | } 29 | 30 | // PolicyResponse contains information about the policy update. 31 | type PolicyResponse struct { 32 | // Newly created roles. 33 | CreatedRoles map[string]CreatedRole `json:"created_roles"` 34 | // The version number of the policy. 35 | Version uint32 `json:"version"` 36 | } 37 | 38 | // DryRunPolicyResponseItems contains Conjur Resources. 39 | type DryRunPolicyResponseItems struct { 40 | Items []Resource `json:"items"` 41 | } 42 | 43 | // DryRunError contains information about any errors that occurred during 44 | // policy validation. 45 | type DryRunError struct { 46 | Line int `json:"line"` 47 | Column int `json:"column"` 48 | Message string `json:"message"` 49 | } 50 | 51 | // DryRunPolicyUpdates defines the specific policy dry run response details on 52 | // which policy updates are modified by a policy load. 53 | type DryRunPolicyUpdates struct { 54 | Before DryRunPolicyResponseItems `json:"before"` 55 | After DryRunPolicyResponseItems `json:"after"` 56 | } 57 | 58 | // DryRunPolicyResponse contains information about the policy validation and 59 | // whether it was successful. 60 | type DryRunPolicyResponse struct { 61 | // Status of the policy validation. 62 | Status string `json:"status"` 63 | Created DryRunPolicyResponseItems `json:"created"` 64 | Updated DryRunPolicyUpdates `json:"updated"` 65 | Deleted DryRunPolicyResponseItems `json:"deleted"` 66 | Errors []DryRunError `json:"errors"` 67 | } 68 | 69 | // LoadPolicy submits new policy data or policy changes to the server. 70 | // 71 | // The required permission depends on the mode. 72 | func (c *Client) LoadPolicy(mode PolicyMode, policyID string, policy io.Reader) (*PolicyResponse, error) { 73 | req, err := c.LoadPolicyRequest(mode, policyID, policy, false) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | resp, err := c.SubmitRequest(req) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | policyResponse := PolicyResponse{} 84 | return &policyResponse, response.JSONResponse(resp, &policyResponse) 85 | } 86 | 87 | func (c *Client) DryRunPolicy(mode PolicyMode, policyID string, policy io.Reader) (*DryRunPolicyResponse, error) { 88 | if isConjurCloudURL(c.config.ApplianceURL) { 89 | return nil, errors.New("Policy Dry Run is not supported in Conjur Cloud") 90 | } 91 | err := c.VerifyMinServerVersion("1.21.1") 92 | if err != nil { 93 | return nil, fmt.Errorf("Policy Dry Run is not supported in Conjur versions older than 1.21.1") 94 | } 95 | 96 | req, err := c.LoadPolicyRequest(mode, policyID, policy, true) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | resp, err := c.SubmitRequest(req) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | policyResponse := DryRunPolicyResponse{} 107 | return &policyResponse, response.DryRunPolicyJSONResponse(resp, &policyResponse) 108 | } 109 | 110 | // FetchPolicy creates a request to fetch policy from the system 111 | func (c *Client) FetchPolicy(policyID string, returnJSON bool, policyTreeDepth uint, sizeLimit uint) ([]byte, error) { 112 | if isConjurCloudURL(c.config.ApplianceURL) { 113 | return nil, errors.New("Policy Fetch is not supported in Conjur Cloud") 114 | } 115 | err := c.VerifyMinServerVersion("1.21.1") 116 | if err != nil { 117 | return nil, fmt.Errorf("Policy Fetch is not supported in Conjur versions older than 1.21.1") 118 | } 119 | 120 | req, err := c.fetchPolicyRequest(policyID, returnJSON, policyTreeDepth, sizeLimit) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | resp, err := c.SubmitRequest(req) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | return response.DataResponse(resp) 131 | } 132 | -------------------------------------------------------------------------------- /conjurapi/requests_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestUnopinionatedParseID(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | input string 13 | want []string 14 | }{ 15 | { 16 | name: "simple full id", 17 | input: "account:kind:identifier", 18 | want: []string{"account", "kind", "identifier"}, 19 | }, 20 | { 21 | name: "simple kind and identifier", 22 | input: "kind:identifier", 23 | want: []string{"", "kind", "identifier"}, 24 | }, 25 | { 26 | name: "simple identifier", 27 | input: "identifier", 28 | want: []string{"", "", "identifier"}, 29 | }, 30 | { 31 | name: "empty string", 32 | input: "", 33 | want: []string{"", "", ""}, 34 | }, 35 | { 36 | name: "empty string with colon", 37 | input: "::", 38 | want: []string{"", "", ""}, 39 | }, 40 | { 41 | name: "full id with colon", 42 | input: "account:kind:ident:ifier", 43 | want: []string{"account", "kind", "ident:ifier"}, 44 | }, 45 | { 46 | name: "full id with multiple colons", 47 | input: "account:kind:ident:ifier:extra", 48 | want: []string{"account", "kind", "ident:ifier:extra"}, 49 | }, 50 | { 51 | name: "ambiguous full or partial id", 52 | // This is ambiguous, but we should treat it as a full id 53 | input: "some:variable:name", 54 | want: []string{"some", "variable", "name"}, 55 | }, 56 | } 57 | 58 | for _, tc := range testCases { 59 | t.Run(tc.name, func(t *testing.T) { 60 | account, kind, id := unopinionatedParseID(tc.input) 61 | got := []string{account, kind, id} 62 | assert.Equal(t, tc.want, got) 63 | }) 64 | } 65 | } 66 | 67 | func TestMakeFullID(t *testing.T) { 68 | testCases := []struct { 69 | name string 70 | input []string 71 | want string 72 | }{ 73 | { 74 | name: "simple full id", 75 | input: []string{"account", "kind", "identifier"}, 76 | want: "account:kind:identifier", 77 | }, 78 | { 79 | name: "simple kind and identifier", 80 | input: []string{"", "kind", "identifier"}, 81 | want: ":kind:identifier", 82 | }, 83 | { 84 | name: "simple identifier", 85 | input: []string{"", "", "identifier"}, 86 | want: "::identifier", 87 | }, 88 | { 89 | name: "empty string", 90 | input: []string{"", "", ""}, 91 | want: "::", 92 | }, 93 | { 94 | name: "full id with colon", 95 | input: []string{"account", "kind", "ident:ifier"}, 96 | want: "account:kind:ident:ifier", 97 | }, 98 | { 99 | name: "full id with multiple colons", 100 | input: []string{"account", "kind", "ident:ifier:extra"}, 101 | want: "account:kind:ident:ifier:extra", 102 | }, 103 | { 104 | name: "full id in last param", 105 | input: []string{"", "", "account:kind:identifier"}, 106 | want: "account:kind:identifier", 107 | }, 108 | { 109 | name: "full id with colon in last param", 110 | input: []string{"", "", "account:kind:ident:ifier"}, 111 | want: "account:kind:ident:ifier", 112 | }, 113 | { 114 | name: "full id with multiple colons in last param", 115 | input: []string{"", "", "account:kind:ident:ifier:extra"}, 116 | want: "account:kind:ident:ifier:extra", 117 | }, 118 | { 119 | name: "ambiguous full or partial id", 120 | // This is ambiguous, but we should treat it as a full id 121 | input: []string{"", "", "some:variable:name"}, 122 | want: "some:variable:name", 123 | }, 124 | { 125 | name: "ambiguous full or partial id with matching account", 126 | // This is ambiguous, but we should treat it as a full id 127 | input: []string{"account", "variable", "account:variable:name"}, 128 | want: "account:variable:name", 129 | }, 130 | { 131 | name: "ambiguous full or partial id with non-matching account", 132 | // This is ambiguous, but we should treat it as a partial ID since the account doesn't match 133 | input: []string{"account", "variable", "some:variable:name"}, 134 | want: "account:variable:some:variable:name", 135 | }, 136 | { 137 | name: "ambiguous full or partial id with non-matching kind", 138 | // This is ambiguous, but we should treat it as a partial ID since the kind doesn't match 139 | input: []string{"account", "variable", "account:kind:name"}, 140 | want: "account:variable:account:kind:name", 141 | }, 142 | } 143 | 144 | for _, tc := range testCases { 145 | t.Run(tc.name, func(t *testing.T) { 146 | got := makeFullID(tc.input[0], tc.input[1], tc.input[2]) 147 | assert.Equal(t, tc.want, got) 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /conjurapi/resource.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/cyberark/conjur-api-go/conjurapi/response" 9 | ) 10 | 11 | // Resource contains information about the Conjur Resource 12 | type Resource struct { 13 | /* 14 | There are two types of resources in conjur: 15 | Roles, which can be given given permissions on other resources and granted other roles, and 16 | Non-Role Resources, which cannot be given given permissions or granted roles. 17 | 18 | Types of Roles: 19 | * Group 20 | * Host 21 | * Layer 22 | * Policy 23 | * User 24 | Types of Non-Role Resources: 25 | * Variable 26 | * Webservice 27 | */ 28 | 29 | // * Fields for all resources 30 | Identifier string `json:"identifier"` 31 | Id string `json:"id"` 32 | Type string `json:"type"` 33 | Owner string `json:"owner"` 34 | Policy string `json:"policy"` 35 | Annotations map[string]string `json:"annotations"` 36 | 37 | // * Field exlusively for roles 38 | Permitted *map[string][]string `json:"permitted,omitempty"` 39 | 40 | // * Fields that we do not put into json for Roles 41 | Permissions *map[string][]string `json:"permissions,omitempty"` 42 | Members *[]string `json:"members,omitempty"` 43 | Memberships *[]string `json:"memberships,omitempty"` 44 | RestrictedTo *[]string `json:"restricted_to,omitempty"` 45 | } 46 | 47 | type ResourceFilter struct { 48 | Kind string 49 | Search string 50 | Limit int 51 | Offset int 52 | Role string 53 | } 54 | 55 | type ResourcesCount struct { 56 | Count int `json:"count"` 57 | } 58 | 59 | // CheckPermission determines whether the authenticated user has a specified privilege 60 | // on a resource. 61 | func (c *Client) CheckPermission(resourceID string, privilege string) (bool, error) { 62 | req, err := c.CheckPermissionRequest(resourceID, privilege) 63 | if err != nil { 64 | return false, err 65 | } 66 | 67 | return c.processPermissionCheck(req) 68 | } 69 | 70 | // CheckPermissionForRole determines whether the provided role has a specific 71 | // privilege on a resource. 72 | func (c *Client) CheckPermissionForRole(resourceID string, roleID string, privilege string) (bool, error) { 73 | req, err := c.CheckPermissionForRoleRequest(resourceID, roleID, privilege) 74 | if err != nil { 75 | return false, err 76 | } 77 | 78 | return c.processPermissionCheck(req) 79 | } 80 | 81 | func (c *Client) processPermissionCheck(req *http.Request) (bool, error) { 82 | resp, err := c.SubmitRequest(req) 83 | if err != nil { 84 | return false, err 85 | } 86 | 87 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { 88 | return true, nil 89 | } else if resp.StatusCode == 404 || resp.StatusCode == 403 { 90 | return false, nil 91 | } else { 92 | return false, fmt.Errorf("Permission check failed with HTTP status %d", resp.StatusCode) 93 | } 94 | } 95 | 96 | // ResourceExists checks whether or not a resource exists 97 | func (c *Client) ResourceExists(resourceID string) (bool, error) { 98 | req, err := c.ResourceRequest(resourceID) 99 | if err != nil { 100 | return false, err 101 | } 102 | 103 | resp, err := c.SubmitRequest(req) 104 | if err != nil { 105 | return false, err 106 | } 107 | 108 | if (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == 403 { 109 | return true, nil 110 | } else if resp.StatusCode == 404 { 111 | return false, nil 112 | } else { 113 | return false, fmt.Errorf("Resource exists check failed with HTTP status %d", resp.StatusCode) 114 | } 115 | } 116 | 117 | // Resource fetches a single user-visible resource by id. 118 | func (c *Client) Resource(resourceID string) (resource map[string]interface{}, err error) { 119 | req, err := c.ResourceRequest(resourceID) 120 | if err != nil { 121 | return 122 | } 123 | 124 | resp, err := c.SubmitRequest(req) 125 | if err != nil { 126 | return 127 | } 128 | 129 | data, err := response.DataResponse(resp) 130 | if err != nil { 131 | return 132 | } 133 | 134 | resource = make(map[string]interface{}) 135 | err = json.Unmarshal(data, &resource) 136 | return 137 | } 138 | 139 | // Resources fetches user-visible resources. The set of resources can 140 | // be limited by the given ResourceFilter. If filter is non-nil, only 141 | // non-zero-valued members of the filter will be applied. 142 | func (c *Client) Resources(filter *ResourceFilter) (resources []map[string]interface{}, err error) { 143 | req, err := c.ResourcesRequest(filter) 144 | if err != nil { 145 | return 146 | } 147 | 148 | resp, err := c.SubmitRequest(req) 149 | if err != nil { 150 | return 151 | } 152 | 153 | data, err := response.DataResponse(resp) 154 | if err != nil { 155 | return 156 | } 157 | 158 | resources = make([]map[string]interface{}, 1) 159 | err = json.Unmarshal(data, &resources) 160 | return 161 | } 162 | 163 | // ResourcesCount counts user-visible resources. The set of resources can 164 | // be limited by the given ResourceFilter. If filter is non-nil, only 165 | // non-zero-valued members of the filter will be applied. 166 | func (c *Client) ResourcesCount(filter *ResourceFilter) (*ResourcesCount, error) { 167 | req, err := c.ResourcesCountRequest(filter) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | resp, err := c.SubmitRequest(req) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | data, err := response.DataResponse(resp) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | resourcesCount := &ResourcesCount{} 183 | err = json.Unmarshal(data, resourcesCount) 184 | return resourcesCount, nil 185 | } 186 | 187 | func (c *Client) ResourceIDs(filter *ResourceFilter) ([]string, error) { 188 | resources, err := c.Resources(filter) 189 | 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | resourceIDs := make([]string, 0) 195 | 196 | for _, element := range resources { 197 | resourceIDs = append(resourceIDs, element["id"].(string)) 198 | } 199 | 200 | return resourceIDs, nil 201 | } 202 | 203 | // PermittedRoles lists the roles which have the named permission on a resource 204 | func (c *Client) PermittedRoles(resourceID, privilege string) ([]string, error) { 205 | req, err := c.PermittedRolesRequest(resourceID, privilege) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | resp, err := c.SubmitRequest(req) 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | data, err := response.DataResponse(resp) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | roles := make([]string, 0) 221 | err = json.Unmarshal(data, &roles) 222 | return roles, nil 223 | } 224 | -------------------------------------------------------------------------------- /conjurapi/resource_json_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | const ( 12 | jsonA = ` 13 | { 14 | "identifier": "demo:user:alice", 15 | "id": "alice", 16 | "type": "user", 17 | "owner": "demo:user:admin", 18 | "policy": "demo:user:example", 19 | "annotations": {"key": "value"}, 20 | "permissions": {"execute": ["demo:variable:example/alpha/secret01","demo:variable"]}, 21 | "members": ["demo:user:admin"],"memberships": ["demo:group:secret-users"], 22 | "restricted_to": ["127.0.0.1"] 23 | }` 24 | 25 | jsonB = ` 26 | { 27 | "identifier": "cucumber:variable:example/alpha/secret01", 28 | "id": "example/alpha/secret01", 29 | "type": "variable", 30 | "owner": "cucumber:policy:example/alpha", 31 | "policy": "cucumber:policy:root", 32 | "permitted": { 33 | "execute": [ 34 | "cucumber:group:example/alpha/secret-users" 35 | ], 36 | "read": [ 37 | "cucumber:group:example/alpha/secret-users" 38 | ] 39 | }, 40 | "annotations": { 41 | "key": "value" 42 | } 43 | }` 44 | ) 45 | 46 | var ( 47 | resourceA = Resource{ 48 | Identifier: "demo:user:alice", 49 | Id: "alice", 50 | Type: "user", 51 | Owner: "demo:user:admin", 52 | Policy: "demo:user:example", 53 | Annotations: map[string]string{"key": "value"}, 54 | Permissions: &map[string][]string{ 55 | "execute": { 56 | "demo:variable:example/alpha/secret01", 57 | "demo:variable", 58 | }, 59 | }, 60 | Members: &[]string{"demo:user:admin"}, 61 | Memberships: &[]string{"demo:group:secret-users"}, 62 | RestrictedTo: &[]string{"127.0.0.1"}, 63 | } 64 | 65 | resourceB = Resource{ 66 | Identifier: "cucumber:variable:example/alpha/secret01", 67 | Id: "example/alpha/secret01", 68 | Type: "variable", 69 | Owner: "cucumber:policy:example/alpha", 70 | Policy: "cucumber:policy:root", 71 | Permitted: &map[string][]string{ 72 | "execute": []string{"cucumber:group:example/alpha/secret-users"}, 73 | "read": []string{"cucumber:group:example/alpha/secret-users"}, 74 | }, 75 | Annotations: map[string]string{"key": "value"}, 76 | } 77 | resourceList = []Resource{resourceA, resourceB} 78 | ) 79 | 80 | func TestResource_UnmarshalJSON(t *testing.T) { 81 | var unmarshalledResource Resource 82 | tests := []struct { 83 | name string 84 | arg string 85 | want Resource 86 | }{ 87 | { 88 | name: "Unmarshal Role", 89 | arg: jsonA, 90 | want: resourceA, 91 | }, 92 | { 93 | name: "Unmarshal Resource", 94 | arg: jsonB, 95 | want: resourceB, 96 | }, 97 | } 98 | for _, tt := range tests { 99 | t.Run(tt.name, func(t *testing.T) { 100 | json.Unmarshal([]byte(tt.arg), &unmarshalledResource) 101 | assert.Equal(t, &tt.want, &unmarshalledResource) 102 | unmarshalledResource = Resource{} 103 | }) 104 | } 105 | } 106 | 107 | func TestResource_MarshalJSON(t *testing.T) { 108 | tests := []struct { 109 | name string 110 | arg Resource 111 | want string 112 | }{ 113 | { 114 | name: "Marshal Role", 115 | arg: resourceA, 116 | want: jsonA, 117 | }, 118 | { 119 | name: "Marshal Resource", 120 | arg: resourceB, 121 | want: jsonB, 122 | }, 123 | } 124 | for _, tt := range tests { 125 | t.Run(tt.name, func(t *testing.T) { 126 | result, err := json.Marshal(tt.arg) 127 | assert.Nil(t, err) 128 | assert.JSONEq(t, tt.want, string(result)) 129 | }) 130 | } 131 | } 132 | 133 | func TestResources_MarshalJSON(t *testing.T) { 134 | tests := []struct { 135 | name string 136 | arg []Resource 137 | want string 138 | }{ 139 | { 140 | name: "Marshal List", 141 | arg: resourceList, 142 | want: fmt.Sprintf("[%s,%s]", jsonA, jsonB), 143 | }, 144 | } 145 | for _, tt := range tests { 146 | t.Run(tt.name, func(t *testing.T) { 147 | result, err := json.Marshal(tt.arg) 148 | assert.Nil(t, err) 149 | assert.JSONEq(t, tt.want, string(result)) 150 | }) 151 | } 152 | } 153 | func TestResources_UnmarshalJSON(t *testing.T) { 154 | var unmarshalledResources []Resource 155 | tests := []struct { 156 | name string 157 | arg string 158 | want []Resource 159 | }{ 160 | { 161 | name: "Unmarshal List", 162 | arg: fmt.Sprintf("[%s,%s]", jsonA, jsonB), 163 | want: resourceList, 164 | }, 165 | } 166 | for _, tt := range tests { 167 | t.Run(tt.name, func(t *testing.T) { 168 | json.Unmarshal([]byte(tt.arg), &unmarshalledResources) 169 | assert.Equal(t, &tt.want, &unmarshalledResources) 170 | }) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /conjurapi/resource_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cyberark/conjur-api-go/conjurapi/authn" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type checkAssertion func(t *testing.T, result bool, err error) 12 | 13 | func assertSuccess(t *testing.T, result bool, err error) { 14 | assert.True(t, result) 15 | assert.NoError(t, err) 16 | } 17 | 18 | func assertFailure(t *testing.T, result bool, err error) { 19 | assert.False(t, result) 20 | assert.NoError(t, err) 21 | } 22 | 23 | func assertError(t *testing.T, result bool, err error) { 24 | assert.False(t, result) 25 | assert.Error(t, err) 26 | } 27 | 28 | func checkAndAssert( 29 | conjur *Client, 30 | assertion checkAssertion, 31 | args ...string, 32 | ) func(t *testing.T) { 33 | return func(t *testing.T) { 34 | var result bool 35 | var err error 36 | 37 | if len(args) == 1 { 38 | result, err = conjur.CheckPermission(args[0], "execute") 39 | } else if len(args) == 2 { 40 | result, err = conjur.CheckPermissionForRole(args[0], args[1], "execute") 41 | } 42 | 43 | assertion(t, result, err) 44 | } 45 | } 46 | 47 | func TestClient_CheckPermission(t *testing.T) { 48 | utils, err := NewTestUtils(&Config{}) 49 | assert.NoError(t, err) 50 | 51 | _, err = utils.Setup(utils.DefaultTestPolicy()) 52 | assert.NoError(t, err) 53 | conjur := utils.Client() 54 | 55 | t.Run( 56 | "Check an allowed permission for default role", 57 | checkAndAssert(conjur, assertSuccess, "conjur:variable:data/test/db-password"), 58 | ) 59 | t.Run( 60 | "Check a permission on a non-existent resource", 61 | checkAndAssert(conjur, assertFailure, "conjur:variable:data/test/foobar"), 62 | ) 63 | t.Run( 64 | "Check a permission on account-less resource", 65 | checkAndAssert(conjur, assertSuccess, "variable:data/test/db-password"), 66 | ) 67 | t.Run( 68 | "Malformed resource id", 69 | checkAndAssert(conjur, assertError, "malformed_id"), 70 | ) 71 | } 72 | 73 | func TestClient_CheckPermissionForRole(t *testing.T) { 74 | utils, err := NewTestUtils(&Config{}) 75 | assert.NoError(t, err) 76 | 77 | _, err = utils.Setup(utils.DefaultTestPolicy()) 78 | assert.NoError(t, err) 79 | conjur := utils.Client() 80 | 81 | t.Run( 82 | "Check an allowed permission for a role", 83 | checkAndAssert(conjur, assertSuccess, "conjur:variable:data/test/db-password", "conjur:host:data/test/bob"), 84 | ) 85 | t.Run( 86 | "Check a permission on a non-existent resource", 87 | checkAndAssert(conjur, assertFailure, "conjur:variable:data/test/foobar", "conjur:host:data/test/bob"), 88 | ) 89 | t.Run( 90 | "Check no permission for a role", 91 | checkAndAssert(conjur, assertFailure, "conjur:variable:data/test/db-password", "conjur:host:data/test/jimmy"), 92 | ) 93 | t.Run( 94 | "Check a permission with empty role", 95 | checkAndAssert(conjur, assertError, "conjur:variable:data/test/db-password", ""), 96 | ) 97 | t.Run( 98 | "Check a permission for account-less role", 99 | checkAndAssert(conjur, assertSuccess, "variable:data/test/db-password", "host:data/test/bob"), 100 | ) 101 | t.Run( 102 | "Malformed resource id", 103 | checkAndAssert(conjur, assertError, "malformed_id", "conjur:host:data/test/bob"), 104 | ) 105 | } 106 | 107 | func TestClient_ResourceExists(t *testing.T) { 108 | resourceExistent := func(conjur *Client, id string) func(t *testing.T) { 109 | return func(t *testing.T) { 110 | exists, err := conjur.ResourceExists(id) 111 | assert.NoError(t, err) 112 | assert.True(t, exists) 113 | } 114 | } 115 | 116 | resourceNonexistent := func(conjur *Client, id string) func(t *testing.T) { 117 | return func(t *testing.T) { 118 | exists, err := conjur.ResourceExists(id) 119 | assert.NoError(t, err) 120 | assert.False(t, exists) 121 | } 122 | } 123 | 124 | utils, err := NewTestUtils(&Config{}) 125 | assert.NoError(t, err) 126 | 127 | _, err = utils.Setup(utils.DefaultTestPolicy()) 128 | assert.NoError(t, err) 129 | conjur := utils.Client() 130 | 131 | t.Run("Resource exists returns true", resourceExistent(conjur, "conjur:variable:data/test/db-password")) 132 | t.Run("Resource exists returns false", resourceNonexistent(conjur, "conjur:variable:data/test/nonexistent")) 133 | } 134 | 135 | var resourceTestPolicy = ` 136 | - !host 137 | id: kate 138 | annotations: 139 | authn/api-key: true 140 | 141 | - !policy 142 | id: database-policy 143 | owner: !host kate 144 | body: 145 | - !host dev/db-host 146 | - !host prod/db-host 147 | - &variables 148 | - !variable secret1 149 | - !variable secret2 150 | - !variable secret3 151 | - !variable secret4 152 | - !variable secret5 153 | - !variable secret6 154 | - !variable prod/db-login 155 | - !variable prod/db-password 156 | 157 | - !permit 158 | role: !host database-policy/prod/db-host 159 | privilege: [ read ] 160 | resource: !variable database-policy/prod/db-login 161 | 162 | - !permit 163 | role: !host database-policy/prod/db-host 164 | privilege: [ read ] 165 | resource: !variable database-policy/prod/db-password 166 | ` 167 | 168 | func TestClient_Resources(t *testing.T) { 169 | listResources := func(conjur *Client, filter *ResourceFilter, expected int) func(t *testing.T) { 170 | return func(t *testing.T) { 171 | resources, err := conjur.Resources(filter) 172 | require.NoError(t, err) 173 | assert.Len(t, resources, expected) 174 | } 175 | } 176 | 177 | utils, err := NewTestUtils(&Config{}) 178 | require.NoError(t, err) 179 | 180 | keys, err := utils.Setup(resourceTestPolicy) 181 | require.NoError(t, err) 182 | 183 | config := Config{} 184 | config.mergeEnv() 185 | 186 | // file deepcode ignore NoHardcodedCredentials/test: This is a test file 187 | conjur, err := NewClientFromKey(config, authn.LoginPair{Login: "host/data/test/kate", APIKey: keys["kate"]}) 188 | require.NoError(t, err) 189 | 190 | t.Run("Lists all resources", listResources(conjur, nil, 11)) 191 | t.Run("Lists resources by kind", listResources(conjur, &ResourceFilter{Kind: "variable"}, 8)) 192 | t.Run("Lists resources that start with db", listResources(conjur, &ResourceFilter{Search: "db"}, 4)) 193 | t.Run("Lists variables that start with prod/database", listResources(conjur, &ResourceFilter{Search: "prod/db", Kind: "variable"}, 2)) 194 | t.Run("Lists resources and limit result to 1", listResources(conjur, &ResourceFilter{Limit: 1}, 1)) 195 | t.Run("Lists resources after the first", listResources(conjur, &ResourceFilter{Offset: 1, Limit: 50}, 10)) 196 | t.Run("Lists resources that prod/db-host can see", listResources(conjur, &ResourceFilter{Role: "conjur:host:data/test/database-policy/prod/db-host"}, 2)) 197 | } 198 | 199 | func TestClient_ResourcesCount(t *testing.T) { 200 | listResourcesCount := func(conjur *Client, filter *ResourceFilter, expected int) func(t *testing.T) { 201 | return func(t *testing.T) { 202 | resourcesCount, err := conjur.ResourcesCount(filter) 203 | require.NoError(t, err) 204 | assert.Equal(t, resourcesCount.Count, expected) 205 | } 206 | } 207 | 208 | utils, err := NewTestUtils(&Config{}) 209 | require.NoError(t, err) 210 | 211 | keys, err := utils.Setup(resourceTestPolicy) 212 | require.NoError(t, err) 213 | 214 | config := Config{} 215 | config.mergeEnv() 216 | 217 | // file deepcode ignore NoHardcodedCredentials/test: This is a test file 218 | conjur, err := NewClientFromKey(config, authn.LoginPair{Login: "host/data/test/kate", APIKey: keys["kate"]}) 219 | require.NoError(t, err) 220 | 221 | t.Run("Counts all resources", listResourcesCount(conjur, nil, 11)) 222 | t.Run("Counts resources filtered by kind", listResourcesCount(conjur, &ResourceFilter{Kind: "variable"}, 8)) 223 | t.Run("Counts resources that start with db", listResourcesCount(conjur, &ResourceFilter{Search: "db"}, 4)) 224 | t.Run("Counts variables that start with prod/database", listResourcesCount(conjur, &ResourceFilter{Search: "prod/db", Kind: "variable"}, 2)) 225 | t.Run("Counts resources and limit result to 1", listResourcesCount(conjur, &ResourceFilter{Limit: 1}, 1)) 226 | t.Run("Counts resources when offset is used", listResourcesCount(conjur, &ResourceFilter{Offset: 1, Limit: 50}, 10)) 227 | t.Run("Counts resources for role with limited access to resources", listResourcesCount(conjur, &ResourceFilter{Role: "conjur:host:data/test/database-policy/prod/db-host"}, 2)) 228 | } 229 | 230 | func TestClient_Resource(t *testing.T) { 231 | showResource := func(conjur *Client, id string) func(t *testing.T) { 232 | return func(t *testing.T) { 233 | _, err := conjur.Resource(id) 234 | assert.NoError(t, err) 235 | } 236 | } 237 | 238 | utils, err := NewTestUtils(&Config{}) 239 | assert.NoError(t, err) 240 | 241 | _, err = utils.Setup(utils.DefaultTestPolicy()) 242 | assert.NoError(t, err) 243 | conjur := utils.Client() 244 | t.Run("Shows a resource", showResource(conjur, "conjur:variable:data/test/db-password")) 245 | } 246 | 247 | func TestClient_ResourceIDs(t *testing.T) { 248 | listResourceIDs := func(conjur *Client, filter *ResourceFilter, expected int) func(t *testing.T) { 249 | return func(t *testing.T) { 250 | resources, err := conjur.ResourceIDs(filter) 251 | assert.NoError(t, err) 252 | assert.Len(t, resources, expected) 253 | } 254 | } 255 | 256 | utils, err := NewTestUtils(&Config{}) 257 | require.NoError(t, err) 258 | 259 | keys, err := utils.Setup(resourceTestPolicy) 260 | require.NoError(t, err) 261 | 262 | config := Config{} 263 | config.mergeEnv() 264 | 265 | conjur, err := NewClientFromKey(config, authn.LoginPair{Login: "host/data/test/kate", APIKey: keys["kate"]}) 266 | require.NoError(t, err) 267 | 268 | t.Run("Lists all resources", listResourceIDs(conjur, nil, 11)) 269 | t.Run("Lists resources by kind", listResourceIDs(conjur, &ResourceFilter{Kind: "variable"}, 8)) 270 | t.Run("Lists resources that start with db", listResourceIDs(conjur, &ResourceFilter{Search: "db"}, 4)) 271 | t.Run("Lists variables that start with prod/database", listResourceIDs(conjur, &ResourceFilter{Search: "prod/db", Kind: "variable"}, 2)) 272 | t.Run("Lists resources and limit result to 1", listResourceIDs(conjur, &ResourceFilter{Limit: 1}, 1)) 273 | t.Run("Lists resources after the first", listResourceIDs(conjur, &ResourceFilter{Offset: 1, Limit: 50}, 10)) 274 | t.Run("Lists resources that prod/db-host can see", listResourceIDs(conjur, &ResourceFilter{Role: "conjur:host:data/test/database-policy/prod/db-host"}, 2)) 275 | } 276 | 277 | func TestClient_PermittedRoles(t *testing.T) { 278 | listPermittedRoles := func(conjur *Client, resourceID string, expected int) func(t *testing.T) { 279 | return func(t *testing.T) { 280 | roles, err := conjur.PermittedRoles(resourceID, "execute") 281 | assert.NoError(t, err) 282 | assert.Len(t, roles, expected) 283 | } 284 | } 285 | 286 | utils, err := NewTestUtils(&Config{}) 287 | assert.NoError(t, err) 288 | 289 | _, err = utils.Setup(utils.DefaultTestPolicy()) 290 | assert.NoError(t, err) 291 | conjur := utils.Client() 292 | assert.NoError(t, err) 293 | 294 | t.Run("Lists permitted roles on a variable", listPermittedRoles(conjur, "conjur:variable:data/test/db-password", 4)) 295 | } 296 | -------------------------------------------------------------------------------- /conjurapi/response/error.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 10 | ) 11 | 12 | type ConjurError struct { 13 | Code int 14 | Message string 15 | Details *ConjurErrorDetails `json:"error"` 16 | } 17 | 18 | type ConjurErrorDetails struct { 19 | Message string 20 | Code string 21 | Target string 22 | Details map[string]interface{} 23 | } 24 | 25 | func NewConjurError(resp *http.Response) error { 26 | defer resp.Body.Close() 27 | body, err := io.ReadAll(resp.Body) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | cerr := ConjurError{} 33 | cerr.Code = resp.StatusCode 34 | err = json.Unmarshal(body, &cerr) 35 | if err != nil { 36 | cerr.Message = strings.TrimSpace(string(body)) 37 | } 38 | 39 | // If the body's empty, use the HTTP status as the message 40 | if cerr.Message == "" { 41 | cerr.Message = resp.Status 42 | } 43 | 44 | return &cerr 45 | } 46 | 47 | func (cerr *ConjurError) Error() string { 48 | logging.ApiLog.Debugf("cerr.Details: %+v, cerr.Message: %+v\n", cerr.Details, cerr.Message) 49 | 50 | var b strings.Builder 51 | 52 | hasMessage := cerr.Message != "" 53 | hasDetails := cerr.Details != nil && cerr.Details.Message != "" 54 | 55 | if hasMessage { 56 | b.WriteString(cerr.Message) 57 | 58 | // If there's both a message and details, separate them with a period and space 59 | if hasDetails { 60 | b.WriteString(". ") 61 | } 62 | } 63 | 64 | if hasDetails { 65 | b.WriteString(cerr.Details.Message + ".") 66 | } 67 | 68 | return b.String() 69 | } 70 | -------------------------------------------------------------------------------- /conjurapi/response/error_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewConjurError(t *testing.T) { 14 | testCases := []struct { 15 | name string 16 | resp *http.Response 17 | expected *ConjurError 18 | }{ 19 | { 20 | name: "simple error", 21 | resp: &http.Response{ 22 | StatusCode: 404, 23 | Status: "Not Found", 24 | Body: io.NopCloser(strings.NewReader(`{"error": {"message": "Not Found"}}`)), 25 | }, 26 | expected: &ConjurError{ 27 | Code: 404, 28 | Message: "Not Found", 29 | Details: &ConjurErrorDetails{ 30 | Message: "Not Found", 31 | }, 32 | }, 33 | }, 34 | { 35 | name: "Conjur error with details", 36 | resp: &http.Response{ 37 | StatusCode: 404, 38 | Status: "Not Found", 39 | Body: io.NopCloser(strings.NewReader(`{"error":{"code":"not_found","message":"CONJ00076E Variable conjur:variable:some_var is empty or not found."}}`)), 40 | }, 41 | expected: &ConjurError{ 42 | Code: 404, 43 | Message: "Not Found", 44 | Details: &ConjurErrorDetails{ 45 | Message: "CONJ00076E Variable conjur:variable:some_var is empty or not found.", 46 | Code: "not_found", 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "empty body", 52 | resp: &http.Response{ 53 | StatusCode: 403, 54 | Status: "Forbidden", 55 | Body: io.NopCloser(strings.NewReader("")), 56 | }, 57 | expected: &ConjurError{ 58 | Code: 403, 59 | Message: "Forbidden", 60 | }, 61 | }, 62 | { 63 | name: "invalid JSON", 64 | resp: &http.Response{ 65 | StatusCode: 403, 66 | Status: "Forbidden", 67 | Body: io.NopCloser(strings.NewReader(`not json`)), 68 | }, 69 | expected: &ConjurError{ 70 | Code: 403, 71 | Message: "Forbidden", 72 | }, 73 | }, 74 | } 75 | 76 | for _, tc := range testCases { 77 | t.Run(tc.name, func(t *testing.T) { 78 | err := NewConjurError(tc.resp) 79 | 80 | require.Error(t, err) 81 | cerr, ok := err.(*ConjurError) 82 | require.True(t, ok, "expected error to be a *ConjurError, got %T", err) 83 | 84 | assert.EqualValues(t, tc.expected.Code, cerr.Code) 85 | }) 86 | } 87 | 88 | t.Run("error reading body", func(t *testing.T) { 89 | resp := &http.Response{ 90 | Body: io.NopCloser(&errorReader{}), 91 | } 92 | 93 | err := NewConjurError(resp) 94 | require.Error(t, err) 95 | assert.EqualError(t, err, "test read error") 96 | }) 97 | } 98 | 99 | func TestConjurError_Error(t *testing.T) { 100 | testCases := []struct { 101 | name string 102 | conjurErr *ConjurError 103 | expected string 104 | }{ 105 | { 106 | name: "with message and details", 107 | conjurErr: &ConjurError{ 108 | Code: 404, 109 | Message: "Not Found", 110 | Details: &ConjurErrorDetails{ 111 | Message: "CONJ00076E Variable conjur:variable:some_var is empty or not found", 112 | Code: "not_found", 113 | }, 114 | }, 115 | expected: "Not Found. CONJ00076E Variable conjur:variable:some_var is empty or not found.", 116 | }, 117 | { 118 | name: "with message only", 119 | conjurErr: &ConjurError{ 120 | Code: 403, 121 | Message: "Forbidden", 122 | }, 123 | expected: "Forbidden", 124 | }, 125 | { 126 | name: "with details only", 127 | conjurErr: &ConjurError{ 128 | Code: 404, 129 | Details: &ConjurErrorDetails{ 130 | Message: "CONJ00076E Variable conjur:variable:some_var is empty or not found", 131 | Code: "not_found", 132 | }, 133 | }, 134 | expected: "CONJ00076E Variable conjur:variable:some_var is empty or not found.", 135 | }, 136 | { 137 | name: "with empty message and details", 138 | conjurErr: &ConjurError{ 139 | Code: 500, 140 | Message: "", 141 | Details: &ConjurErrorDetails{ 142 | Message: "", 143 | Code: "", 144 | }, 145 | }, 146 | expected: "", 147 | }, 148 | } 149 | 150 | for _, tc := range testCases { 151 | t.Run(tc.name, func(t *testing.T) { 152 | actual := tc.conjurErr.Error() 153 | assert.Equal(t, tc.expected, actual) 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /conjurapi/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 9 | ) 10 | 11 | func readBody(resp *http.Response) ([]byte, error) { 12 | defer resp.Body.Close() 13 | 14 | responseText, err := io.ReadAll(resp.Body) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return responseText, err 20 | } 21 | 22 | func logResponse(resp *http.Response) { 23 | req := resp.Request 24 | redactedHeaders := redactHeaders(req.Header) 25 | logging.ApiLog.Debugf("%d %s %s %+v", resp.StatusCode, req.Method, req.URL, redactedHeaders) 26 | } 27 | 28 | const redactedString = "[REDACTED]" 29 | 30 | // redactHeaders purges Authorization headers, and returns a function to restore them. 31 | func redactHeaders(headers http.Header) http.Header { 32 | origAuthz := headers.Get("Authorization") 33 | if origAuthz != "" { 34 | newHeaders := headers.Clone() 35 | newHeaders.Set("Authorization", redactedString) 36 | return newHeaders 37 | } 38 | return headers 39 | } 40 | 41 | // DataResponse checks the HTTP status of the response. If it's less than 42 | // 300, it returns the response body as a byte array. Otherwise it returns 43 | // a NewConjurError. 44 | func DataResponse(resp *http.Response) ([]byte, error) { 45 | logResponse(resp) 46 | if resp.StatusCode < 300 { 47 | return readBody(resp) 48 | } 49 | return nil, NewConjurError(resp) 50 | } 51 | 52 | // SecretDataResponse checks the HTTP status of the response. If it's less than 53 | // 300, it returns the response body as a stream. Otherwise it returns 54 | // a NewConjurError. 55 | func SecretDataResponse(resp *http.Response) (io.ReadCloser, error) { 56 | logResponse(resp) 57 | if resp.StatusCode < 300 { 58 | return resp.Body, nil 59 | } 60 | return nil, NewConjurError(resp) 61 | } 62 | 63 | // JSONResponse checks the HTTP status of the response. If it's less than 64 | // 300, it returns the response body as JSON. Otherwise it returns 65 | // a NewConjurError. 66 | func JSONResponse(resp *http.Response, obj interface{}) error { 67 | logResponse(resp) 68 | if resp.StatusCode < 300 { 69 | body, err := readBody(resp) 70 | if err != nil { 71 | return err 72 | } 73 | return json.Unmarshal(body, obj) 74 | } 75 | return NewConjurError(resp) 76 | } 77 | 78 | // JSONResponseWithAllowedStatusCodes checks the HTTP status of the response. If it's less than 79 | // 300 or equal to one of the provided values, it returns the response body as JSON. Otherwise it 80 | // returns a NewConjurError. 81 | func JSONResponseWithAllowedStatusCodes(resp *http.Response, obj interface{}, allowedStatusCodes []int) error { 82 | logResponse(resp) 83 | if resp.StatusCode < 300 || contains(allowedStatusCodes, resp.StatusCode) { 84 | body, err := readBody(resp) 85 | if err != nil { 86 | return err 87 | } 88 | return json.Unmarshal(body, obj) 89 | } 90 | return NewConjurError(resp) 91 | } 92 | 93 | func contains(allowedStatusCodes []int, i int) bool { 94 | for _, v := range allowedStatusCodes { 95 | if v == i { 96 | return true 97 | } 98 | } 99 | return false 100 | } 101 | 102 | // EmptyResponse checks the HTTP status of the response. If it's less than 103 | // 300, it returns without an error. Otherwise it returns 104 | // a NewConjurError. 105 | func EmptyResponse(resp *http.Response) error { 106 | logResponse(resp) 107 | if resp.StatusCode < 300 { 108 | return nil 109 | } 110 | return NewConjurError(resp) 111 | } 112 | 113 | // DryRunPolicyJSONResponse checks the HTTP status of the response. If it's less than 114 | // 300 or equal to 422, it returns the response body as JSON. Otherwise it 115 | // returns a NewConjurError. 116 | func DryRunPolicyJSONResponse(resp *http.Response, obj interface{}) error { 117 | return JSONResponseWithAllowedStatusCodes(resp, obj, []int{422}) 118 | } 119 | 120 | // AuthenticatorStatusJSONResponse checks the HTTP status of the response. If it's less than 121 | // 300 or equal to 500, it returns the response body as JSON. Otherwise it 122 | // returns a NewConjurError. 123 | func AuthenticatorStatusJSONResponse(resp *http.Response, obj interface{}) error { 124 | return JSONResponseWithAllowedStatusCodes(resp, obj, []int{500}) 125 | } 126 | -------------------------------------------------------------------------------- /conjurapi/role.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/cyberark/conjur-api-go/conjurapi/response" 8 | ) 9 | 10 | // RoleExists checks whether or not a role exists 11 | func (c *Client) RoleExists(roleID string) (bool, error) { 12 | req, err := c.RoleRequest(roleID) 13 | if err != nil { 14 | return false, err 15 | } 16 | 17 | resp, err := c.SubmitRequest(req) 18 | if err != nil { 19 | return false, err 20 | } 21 | 22 | if (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == 403 { 23 | return true, nil 24 | } else if resp.StatusCode == 404 { 25 | return false, nil 26 | } else { 27 | return false, fmt.Errorf("Role exists check failed with HTTP status %d", resp.StatusCode) 28 | } 29 | } 30 | 31 | // Role fetches detailed information about a specific role, including 32 | // the role members 33 | func (c *Client) Role(roleID string) (role map[string]interface{}, err error) { 34 | req, err := c.RoleRequest(roleID) 35 | if err != nil { 36 | return 37 | } 38 | 39 | resp, err := c.SubmitRequest(req) 40 | if err != nil { 41 | return 42 | } 43 | 44 | data, err := response.DataResponse(resp) 45 | if err != nil { 46 | return 47 | } 48 | 49 | role = make(map[string]interface{}) 50 | err = json.Unmarshal(data, &role) 51 | return 52 | } 53 | 54 | // RoleMembers fetches members within a role 55 | func (c *Client) RoleMembers(roleID string) (members []map[string]interface{}, err error) { 56 | req, err := c.RoleMembersRequest(roleID) 57 | if err != nil { 58 | return 59 | } 60 | 61 | resp, err := c.SubmitRequest(req) 62 | if err != nil { 63 | return 64 | } 65 | 66 | data, err := response.DataResponse(resp) 67 | if err != nil { 68 | return 69 | } 70 | 71 | members = make([]map[string]interface{}, 0) 72 | err = json.Unmarshal(data, &members) 73 | return 74 | } 75 | 76 | // RoleMemberships fetches memberships of a role, including 77 | // only roles for which the given ID is a direct member 78 | func (c *Client) RoleMemberships(roleID string) (memberships []map[string]interface{}, err error) { 79 | req, err := c.RoleMembershipsRequest(roleID) 80 | if err != nil { 81 | return 82 | } 83 | 84 | resp, err := c.SubmitRequest(req) 85 | if err != nil { 86 | return 87 | } 88 | 89 | data, err := response.DataResponse(resp) 90 | if err != nil { 91 | return 92 | } 93 | 94 | memberships = make([]map[string]interface{}, 0) 95 | err = json.Unmarshal(data, &memberships) 96 | return 97 | } 98 | 99 | // RoleMembershipsAll fetches all memberships of a role, including 100 | // inherited memberships, returning a list of member IDs 101 | func (c *Client) RoleMembershipsAll(roleID string) (memberships []string, err error) { 102 | req, err := c.RoleMembershipsRequestWithOptions(roleID, true) 103 | if err != nil { 104 | return 105 | } 106 | 107 | resp, err := c.SubmitRequest(req) 108 | if err != nil { 109 | return 110 | } 111 | 112 | data, err := response.DataResponse(resp) 113 | if err != nil { 114 | return 115 | } 116 | 117 | memberships = make([]string, 0) 118 | err = json.Unmarshal(data, &memberships) 119 | return 120 | } 121 | -------------------------------------------------------------------------------- /conjurapi/role_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var roleTestPolicy = ` 11 | - !host bob 12 | - !host jimmy 13 | - !host dean 14 | - !group test-users 15 | - !layer test-layer 16 | 17 | - !variable secret 18 | 19 | - !permit 20 | role: !host bob 21 | privilege: [ execute ] 22 | resource: !variable secret 23 | 24 | - !grant 25 | role: !layer test-layer 26 | members: 27 | - !host jimmy 28 | - !host bob 29 | - !group test-users 30 | 31 | - !grant 32 | role: !group test-users 33 | member: !host dean 34 | ` 35 | 36 | func TestClient_RoleExists(t *testing.T) { 37 | roleExistent := func(conjur *Client, id string) func(t *testing.T) { 38 | return func(t *testing.T) { 39 | exists, err := conjur.RoleExists(id) 40 | assert.NoError(t, err) 41 | assert.True(t, exists) 42 | } 43 | } 44 | 45 | roleNonexistent := func(conjur *Client, id string) func(t *testing.T) { 46 | return func(t *testing.T) { 47 | exists, err := conjur.RoleExists(id) 48 | assert.NoError(t, err) 49 | assert.False(t, exists) 50 | } 51 | } 52 | 53 | roleInvalid := func(conjur *Client, id string) func(t *testing.T) { 54 | return func(t *testing.T) { 55 | exists, err := conjur.RoleExists(id) 56 | assert.Error(t, err) 57 | assert.False(t, exists) 58 | } 59 | } 60 | 61 | utils, err := NewTestUtils(&Config{}) 62 | assert.NoError(t, err) 63 | 64 | _, err = utils.Setup(utils.DefaultTestPolicy()) 65 | assert.NoError(t, err) 66 | conjur := utils.Client() 67 | 68 | t.Run("Role exists returns true", roleExistent(conjur, "conjur:host:data/test/bob")) 69 | t.Run("Role exists returns false", roleNonexistent(conjur, "conjur:user:data/test/nonexistent")) 70 | t.Run("Role exists returns error", roleInvalid(conjur, "")) 71 | } 72 | 73 | func TestClient_Role(t *testing.T) { 74 | showRole := func(conjur *Client, id string) func(t *testing.T) { 75 | return func(t *testing.T) { 76 | _, err := conjur.Role(id) 77 | assert.NoError(t, err) 78 | } 79 | } 80 | 81 | utils, err := NewTestUtils(&Config{}) 82 | assert.NoError(t, err) 83 | 84 | _, err = utils.Setup(roleTestPolicy) 85 | assert.NoError(t, err) 86 | 87 | conjur := utils.Client() 88 | 89 | t.Run("Shows a role", showRole(conjur, "conjur:host:data/test/bob")) 90 | } 91 | 92 | func TestClient_RoleMembers(t *testing.T) { 93 | listMembers := func(conjur *Client, id string, expected int) func(t *testing.T) { 94 | return func(t *testing.T) { 95 | members, err := conjur.RoleMembers(id) 96 | assert.NoError(t, err) 97 | assert.Len(t, members, expected) 98 | } 99 | } 100 | 101 | utils, err := NewTestUtils(&Config{}) 102 | assert.NoError(t, err) 103 | 104 | conjur := utils.Client() 105 | _, err = utils.Setup(roleTestPolicy) 106 | assert.NoError(t, err) 107 | 108 | t.Run("List admin role members return 1 member", listMembers(conjur, fmt.Sprintf("conjur:user:%s", utils.AdminUser()), 1)) 109 | t.Run("List role members return members", listMembers(conjur, "conjur:layer:data/test/test-layer", 4)) 110 | } 111 | 112 | func TestClient_RoleMemberships(t *testing.T) { 113 | testMemberships := func(conjur *Client, id string, expectedDirect, expectedAll int) func(t *testing.T) { 114 | return func(t *testing.T) { 115 | t.Run("Direct memberships only", func(t *testing.T) { 116 | memberships, err := conjur.RoleMemberships(id) 117 | assert.NoError(t, err) 118 | assert.Len(t, memberships, expectedDirect) 119 | }) 120 | 121 | t.Run("All memberships", func(t *testing.T) { 122 | memberships, err := conjur.RoleMembershipsAll(id) 123 | assert.NoError(t, err) 124 | assert.Len(t, memberships, expectedAll) 125 | }) 126 | } 127 | } 128 | 129 | utils, err := NewTestUtils(&Config{}) 130 | assert.NoError(t, err) 131 | 132 | _, err = utils.Setup(roleTestPolicy) 133 | assert.NoError(t, err) 134 | 135 | conjur := utils.Client() 136 | 137 | t.Run("Bob's memberships", testMemberships(conjur, "conjur:host:data/test/bob", 1, 2)) 138 | t.Run("Test layer memberships", testMemberships(conjur, "conjur:layer:data/test/test-layer", 0, 1)) 139 | t.Run("Dean's memberships", testMemberships(conjur, "conjur:host:data/test/dean", 1, 3)) 140 | } 141 | -------------------------------------------------------------------------------- /conjurapi/router_url.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strings" 7 | 8 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 9 | ) 10 | 11 | var ConjurCloudSuffixes = []string{ 12 | ".secretsmgr.cyberark.cloud", 13 | ".secretsmgr.integration-cyberark.cloud", 14 | } 15 | 16 | type routerURL string 17 | 18 | func makeRouterURL(base string, components ...string) routerURL { 19 | urlBase := normalizeBaseURL(base) 20 | urlPath := path.Join(components...) 21 | urlPath = strings.TrimPrefix(urlPath, "/") 22 | return routerURL(urlBase + "/" + urlPath) 23 | } 24 | 25 | func (u routerURL) withFormattedQuery(queryFormat string, queryArgs ...interface{}) routerURL { 26 | query := fmt.Sprintf(queryFormat, queryArgs...) 27 | return routerURL(strings.Join([]string{string(u), query}, "?")) 28 | } 29 | 30 | func (u routerURL) withQuery(query string) routerURL { 31 | return routerURL(strings.Join([]string{string(u), query}, "?")) 32 | } 33 | 34 | func (u routerURL) String() string { 35 | return string(u) 36 | } 37 | 38 | func normalizeBaseURL(baseURL string) string { 39 | url := strings.TrimSuffix(baseURL, "/") 40 | 41 | if isConjurCloudURL(url) && !strings.HasSuffix(url, "/api") { 42 | logging.ApiLog.Info("Detected Conjur Cloud URL, adding '/api' prefix") 43 | return url + "/api" 44 | } 45 | 46 | return url 47 | } 48 | 49 | func isConjurCloudURL(baseURL string) bool { 50 | url := strings.TrimSuffix(baseURL, "/") 51 | 52 | for _, suffix := range ConjurCloudSuffixes { 53 | if strings.HasSuffix(url, suffix) || strings.HasSuffix(url, suffix+"/api") { 54 | return true 55 | } 56 | } 57 | 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /conjurapi/router_url_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_makeRouterURL(t *testing.T) { 10 | t.Run("makeRouterURL removes extra '/'s between base and components", func(t *testing.T) { 11 | urlWithPath := routerURL("http://some.host/some/path") 12 | urlWithSubPath := routerURL("http://some.host/path/to/something/subpath/to/another") 13 | 14 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host", "some/path")) 15 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host/", "//some/path")) 16 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host/", "some//path")) 17 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host/", "some/path//")) 18 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host", "//some/path")) 19 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host", "some//path")) 20 | assert.Equal(t, urlWithPath, makeRouterURL("http://some.host", "some/path//")) 21 | assert.Equal(t, urlWithSubPath, makeRouterURL("http://some.host/path/to/something/", "//subpath//to//another")) 22 | }) 23 | 24 | t.Run("makeRouterURL handles Conjur Cloud base URL", func(t *testing.T) { 25 | cloudUrlWithPath := routerURL("http://some.host.secretsmgr.cyberark.cloud/api/some/path") 26 | cloudUrlWithSubPath := routerURL("http://some.host.secretsmgr.cyberark.cloud/api/some/path/subpath/to/another") 27 | 28 | t.Run("when '/api' prefix is not provided", func(t *testing.T) { 29 | assert.Equal(t, cloudUrlWithPath, makeRouterURL("http://some.host.secretsmgr.cyberark.cloud", "some/path")) 30 | }) 31 | 32 | t.Run("when '/api' prefix is provided", func(t *testing.T) { 33 | assert.Equal(t, cloudUrlWithPath, makeRouterURL("http://some.host.secretsmgr.cyberark.cloud/api", "some/path")) 34 | }) 35 | 36 | t.Run("when adding subpaths", func(t *testing.T) { 37 | assert.Equal(t, cloudUrlWithSubPath, makeRouterURL("http://some.host.secretsmgr.cyberark.cloud/api/some/path/", "subpath/to/another")) 38 | }) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /conjurapi/storage.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 7 | "github.com/cyberark/conjur-api-go/conjurapi/storage" 8 | ) 9 | 10 | const ( 11 | CredentialStorageFile = "file" 12 | CredentialStorageKeyring = "keyring" 13 | CredentialStorageNone = "none" 14 | ) 15 | 16 | func createStorageProvider(config Config) (CredentialStorageProvider, error) { 17 | if config.CredentialStorage == "" { 18 | config.CredentialStorage = getDefaultCredentialStorage() 19 | logging.ApiLog.Debugf("No credential storage specified, defaulting to %s", config.CredentialStorage) 20 | } 21 | 22 | switch config.CredentialStorage { 23 | case CredentialStorageFile: 24 | return storage.NewNetrcStorageProvider( 25 | config.NetRCPath, 26 | getMachineName(config), 27 | ), nil 28 | case CredentialStorageKeyring: 29 | if !storage.IsKeyringAvailable() { 30 | return nil, fmt.Errorf("Keyring is not available") 31 | } 32 | 33 | return storage.NewKeyringStorageProvider( 34 | getMachineName(config), 35 | ), nil 36 | case CredentialStorageNone: 37 | // Don't store credentials 38 | logging.ApiLog.Debugf("Not storing credentials") 39 | return nil, nil 40 | default: 41 | return nil, fmt.Errorf("Unknown credential storage type") 42 | } 43 | } 44 | 45 | // getMachineName returns the machine name to use in the .netrc file or other credential storage. 46 | // It contains the appliance URL and the path to the authentication endpoint. 47 | func getMachineName(config Config) string { 48 | if config.AuthnType != "" && config.AuthnType != "authn" { 49 | authnType := fmt.Sprintf("authn-%s", config.AuthnType) 50 | return fmt.Sprintf("%s/%s/%s", config.ApplianceURL, authnType, config.ServiceID) 51 | } 52 | 53 | return config.ApplianceURL + "/authn" 54 | } 55 | 56 | func getDefaultCredentialStorage() string { 57 | if storage.IsKeyringAvailable() { 58 | return CredentialStorageKeyring 59 | } 60 | 61 | return CredentialStorageFile 62 | } 63 | -------------------------------------------------------------------------------- /conjurapi/storage/keyring_storage_provider.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 7 | "github.com/zalando/go-keyring" 8 | ) 9 | 10 | type KeyringStorageProvider struct { 11 | machineName string 12 | } 13 | 14 | var keyring_keys = []string{"login", "password", "authn_token"} 15 | var ErrWritingCredentials = errors.New("unable to write credentials to keyring") 16 | var ErrReadingCredentials = errors.New("unable to read credentials from keyring") 17 | 18 | func NewKeyringStorageProvider(machineName string) *KeyringStorageProvider { 19 | return &KeyringStorageProvider{ 20 | machineName: machineName, 21 | } 22 | } 23 | 24 | // IsKeyringAvailable returns true if the keyring is available on the system 25 | func IsKeyringAvailable() bool { 26 | // Try to get a value. If there's an error other than "not found", then the 27 | // keyring is not available. 28 | _, err := keyring.Get("test", "test") 29 | return err == keyring.ErrNotFound 30 | } 31 | 32 | func (k *KeyringStorageProvider) StoreCredentials(login string, password string) error { 33 | err := keyring.Set(k.machineName, "login", login) 34 | if err != nil { 35 | logging.ApiLog.Debug(err) 36 | return ErrWritingCredentials 37 | } 38 | 39 | err = keyring.Set(k.machineName, "password", password) 40 | if err != nil { 41 | logging.ApiLog.Debug(err) 42 | return ErrWritingCredentials 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (k *KeyringStorageProvider) ReadCredentials() (string, string, error) { 49 | login, err := keyring.Get(k.machineName, "login") 50 | if err != nil && err != keyring.ErrNotFound { 51 | logging.ApiLog.Debug(err) 52 | return "", "", ErrReadingCredentials 53 | } 54 | password, err := keyring.Get(k.machineName, "password") 55 | if err != nil && err != keyring.ErrNotFound { 56 | logging.ApiLog.Debug(err) 57 | return "", "", ErrReadingCredentials 58 | } 59 | return login, password, nil 60 | } 61 | 62 | func (k *KeyringStorageProvider) ReadAuthnToken() ([]byte, error) { 63 | token, err := keyring.Get(k.machineName, "authn_token") 64 | if err != nil && err != keyring.ErrNotFound { 65 | logging.ApiLog.Debug(err) 66 | return nil, ErrReadingCredentials 67 | } 68 | return []byte(token), nil 69 | } 70 | 71 | func (k *KeyringStorageProvider) StoreAuthnToken(token []byte) error { 72 | err := keyring.Set(k.machineName, "authn_token", string(token)) 73 | if err != nil { 74 | logging.ApiLog.Debug(err) 75 | return ErrWritingCredentials 76 | } 77 | return nil 78 | } 79 | 80 | func (k *KeyringStorageProvider) PurgeCredentials() error { 81 | for _, key := range keyring_keys { 82 | err := keyring.Delete(k.machineName, key) 83 | if err != nil { 84 | logging.ApiLog.Debugf("Error when deleting %s from keyring: %s", key, err) 85 | } 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /conjurapi/storage/keyring_storage_provider_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os" 7 | "testing" 8 | 9 | "github.com/cyberark/conjur-api-go/conjurapi/logging" 10 | "github.com/sirupsen/logrus" 11 | "github.com/zalando/go-keyring" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestIsKeyringAvailable(t *testing.T) { 17 | // Keyring shouldn't be avaialble by default in the container running the tests 18 | // until we enable the mock keyring 19 | assert.False(t, IsKeyringAvailable()) 20 | keyring.MockInit() 21 | assert.True(t, IsKeyringAvailable()) 22 | } 23 | 24 | func TestKeyringStorageProvider_StoreCredentials(t *testing.T) { 25 | testCases := []struct { 26 | name string 27 | expectedKeyValues map[string]string 28 | }{ 29 | { 30 | name: "Stores credentials in keyring", 31 | expectedKeyValues: map[string]string{ 32 | "login": "test-login", 33 | "password": "test-password", 34 | "authn_token": "", 35 | }, 36 | }, 37 | } 38 | 39 | for _, tc := range testCases { 40 | t.Run(tc.name, func(t *testing.T) { 41 | storage := setupTestStorage(t) 42 | 43 | err := storage.StoreCredentials(tc.expectedKeyValues["login"], tc.expectedKeyValues["password"]) 44 | assert.NoError(t, err) 45 | 46 | for key, value := range tc.expectedKeyValues { 47 | item, err := keyring.Get(storage.machineName, key) 48 | 49 | // If the expected value is empty, we expect the key to not be found 50 | if value == "" { 51 | assert.Error(t, err) 52 | assert.ErrorIs(t, err, keyring.ErrNotFound) 53 | continue 54 | } 55 | 56 | // Otherwise, we expect the key to be found and the value to match 57 | assert.NoError(t, err) 58 | assert.Equal(t, value, string(item)) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestKeyringStorageProvider_ReadCredentials(t *testing.T) { 65 | testCases := []struct { 66 | name string 67 | expectedKeyValues map[string]string 68 | }{ 69 | { 70 | name: "Stores credentials in keyring", 71 | expectedKeyValues: map[string]string{ 72 | "login": "test-login", 73 | "password": "test-password", 74 | }, 75 | }, 76 | } 77 | 78 | for _, tc := range testCases { 79 | t.Run(tc.name, func(t *testing.T) { 80 | storage := setupTestStorage(t) 81 | 82 | for key, value := range tc.expectedKeyValues { 83 | keyring.Set(storage.machineName, key, value) 84 | } 85 | 86 | u, p, err := storage.ReadCredentials() 87 | assert.NoError(t, err) 88 | 89 | assert.Equal(t, tc.expectedKeyValues["login"], u) 90 | assert.Equal(t, tc.expectedKeyValues["password"], p) 91 | }) 92 | } 93 | } 94 | 95 | func TestKeyringStorageProvider_StoreAuthToken(t *testing.T) { 96 | testCases := []struct { 97 | name string 98 | expectedKeyValues map[string]string 99 | }{ 100 | { 101 | name: "Stores authn token in keyring", 102 | expectedKeyValues: map[string]string{ 103 | "login": "", 104 | "password": "", 105 | "authn_token": "test-authn-token", 106 | }, 107 | }, 108 | } 109 | 110 | for _, tc := range testCases { 111 | t.Run(tc.name, func(t *testing.T) { 112 | storage := setupTestStorage(t) 113 | 114 | err := storage.StoreAuthnToken([]byte(tc.expectedKeyValues["authn_token"])) 115 | assert.NoError(t, err) 116 | 117 | for key, value := range tc.expectedKeyValues { 118 | item, err := keyring.Get(storage.machineName, key) 119 | 120 | // If the expected value is empty, we expect the key to not be found 121 | if value == "" { 122 | assert.Error(t, err) 123 | assert.ErrorIs(t, err, keyring.ErrNotFound) 124 | continue 125 | } 126 | 127 | // Otherwise, we expect the key to be found and the value to match 128 | assert.NoError(t, err) 129 | assert.Equal(t, value, string(item)) 130 | } 131 | }) 132 | } 133 | } 134 | 135 | func TestKeyringStorageProvider_ReadAuthToken(t *testing.T) { 136 | testCases := []struct { 137 | name string 138 | expectedKeyValues map[string]string 139 | }{ 140 | { 141 | name: "Stores authn token in keyring", 142 | expectedKeyValues: map[string]string{ 143 | "authn_token": "test-authn-token", 144 | }, 145 | }, 146 | } 147 | 148 | for _, tc := range testCases { 149 | t.Run(tc.name, func(t *testing.T) { 150 | storage := setupTestStorage(t) 151 | 152 | for key, value := range tc.expectedKeyValues { 153 | keyring.Set(storage.machineName, key, value) 154 | } 155 | 156 | token, err := storage.ReadAuthnToken() 157 | assert.NoError(t, err) 158 | 159 | assert.Equal(t, tc.expectedKeyValues["authn_token"], string(token)) 160 | }) 161 | } 162 | } 163 | 164 | func TestKeyringStorageProvider_PurgeCredentials(t *testing.T) { 165 | testCases := []struct { 166 | name string 167 | expectedKeyValues map[string]string 168 | }{ 169 | { 170 | name: "Purges credentials from keyring", 171 | expectedKeyValues: map[string]string{ 172 | "login": "test-login", 173 | "password": "test-password", 174 | "authn_token": "test-authn-token", 175 | }, 176 | }, 177 | } 178 | 179 | for _, tc := range testCases { 180 | t.Run(tc.name, func(t *testing.T) { 181 | storage := setupTestStorage(t) 182 | 183 | for key, value := range tc.expectedKeyValues { 184 | keyring.Set(storage.machineName, key, value) 185 | } 186 | 187 | err := storage.PurgeCredentials() 188 | assert.NoError(t, err) 189 | 190 | for key := range tc.expectedKeyValues { 191 | _, err := keyring.Get(storage.machineName, key) 192 | assert.Error(t, err) 193 | assert.ErrorIs(t, err, keyring.ErrNotFound) 194 | } 195 | }) 196 | } 197 | } 198 | 199 | func TestKeyringStorageProvider_ErrorHandling(t *testing.T) { 200 | storage := setupTestStorageWithError(t, errors.New("test error")) 201 | 202 | testCases := []struct { 203 | name string 204 | assert func(t *testing.T, logOutput *bytes.Buffer) 205 | }{ 206 | { 207 | name: "StoreCredentials", 208 | assert: func(t *testing.T, logOutput *bytes.Buffer) { 209 | err := storage.StoreCredentials("test-login", "test-password") 210 | assertWriteError(t, logOutput, err) 211 | }, 212 | }, 213 | { 214 | name: "ReadCredentials", 215 | assert: func(t *testing.T, logOutput *bytes.Buffer) { 216 | _, _, err := storage.ReadCredentials() 217 | assertReadError(t, logOutput, err) 218 | }, 219 | }, 220 | { 221 | name: "StoreAuthnToken", 222 | assert: func(t *testing.T, logOutput *bytes.Buffer) { 223 | err := storage.StoreAuthnToken([]byte("test-authn-token")) 224 | assertWriteError(t, logOutput, err) 225 | }, 226 | }, 227 | { 228 | name: "ReadAuthnToken", 229 | assert: func(t *testing.T, logOutput *bytes.Buffer) { 230 | _, err := storage.ReadAuthnToken() 231 | assertReadError(t, logOutput, err) 232 | }, 233 | }, 234 | { 235 | name: "PurgeCredentials", 236 | assert: func(t *testing.T, logOutput *bytes.Buffer) { 237 | err := storage.PurgeCredentials() 238 | // We expect the error to be logged, but not returned 239 | assert.NoError(t, err) 240 | // There should be a log entry for each key that failed to be deleted 241 | assert.Contains(t, logOutput.String(), "Error when deleting login from keyring: test error") 242 | assert.Contains(t, logOutput.String(), "Error when deleting password from keyring: test error") 243 | assert.Contains(t, logOutput.String(), "Error when deleting authn_token from keyring: test error") 244 | }, 245 | }, 246 | } 247 | 248 | for _, tc := range testCases { 249 | t.Run(tc.name, func(t *testing.T) { 250 | // Intercept the log output 251 | var logOutput bytes.Buffer 252 | logging.ApiLog.SetOutput(&logOutput) 253 | // Set the log level to debug to capture all logs 254 | logging.ApiLog.SetLevel(logrus.DebugLevel) 255 | 256 | tc.assert(t, &logOutput) 257 | 258 | // Reset the log output 259 | t.Cleanup(func() { 260 | logging.ApiLog.SetOutput(os.Stdout) 261 | logging.ApiLog.SetLevel(logrus.InfoLevel) 262 | }) 263 | }) 264 | } 265 | } 266 | 267 | func setupTestStorage(t *testing.T) *KeyringStorageProvider { 268 | // Use a mock, in-memory provider for testing 269 | keyring.MockInit() 270 | 271 | testMachineName := "conjur_api_go_test" + t.TempDir() 272 | storage := NewKeyringStorageProvider(testMachineName) 273 | 274 | t.Cleanup(func() { 275 | for _, key := range keyring_keys { 276 | keyring.Delete(testMachineName, key) 277 | } 278 | }) 279 | 280 | return storage 281 | } 282 | 283 | func setupTestStorageWithError(t *testing.T, err error) *KeyringStorageProvider { 284 | keyring.MockInitWithError(err) 285 | 286 | testMachineName := "conjur_api_go_test" + t.TempDir() 287 | return NewKeyringStorageProvider(testMachineName) 288 | } 289 | 290 | func assertWriteError(t *testing.T, logOutput *bytes.Buffer, err error) { 291 | // Check that the original error is logged but only the wrapped error is returned 292 | assert.ErrorIs(t, err, ErrWritingCredentials) 293 | assert.Contains(t, logOutput.String(), "test error") 294 | } 295 | 296 | func assertReadError(t *testing.T, logOutput *bytes.Buffer, err error) { 297 | // Check that the original error is logged but only the wrapped error is returned 298 | assert.ErrorIs(t, err, ErrReadingCredentials) 299 | assert.Contains(t, logOutput.String(), "test error") 300 | } 301 | -------------------------------------------------------------------------------- /conjurapi/storage/netrc_storage_provider.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/bgentry/go-netrc/netrc" 9 | ) 10 | 11 | type NetrcStorageProvider struct { 12 | netRCPath string 13 | machineName string 14 | } 15 | 16 | func NewNetrcStorageProvider(netRCPath, machineName string) *NetrcStorageProvider { 17 | return &NetrcStorageProvider{ 18 | netRCPath: netRCPath, 19 | machineName: machineName, 20 | } 21 | } 22 | 23 | // StoreCredentials stores credentials to the specified .netrc file 24 | func (s *NetrcStorageProvider) StoreCredentials(login string, password string) error { 25 | err := s.ensureNetrcFileExists() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | nrc, err := netrc.ParseFile(s.netRCPath) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | m := nrc.FindMachine(s.machineName) 36 | if m == nil || m.IsDefault() { 37 | _ = nrc.NewMachine(s.machineName, login, password, "") 38 | } else { 39 | m.UpdateLogin(login) 40 | m.UpdatePassword(password) 41 | } 42 | 43 | data, err := nrc.MarshalText() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | data = ensureEndsWithNewline(data) 49 | 50 | return os.WriteFile(s.netRCPath, data, 0600) 51 | } 52 | 53 | func (s *NetrcStorageProvider) ReadCredentials() (string, string, error) { 54 | nrc, err := netrc.ParseFile(s.netRCPath) 55 | if err != nil { 56 | return "", "", err 57 | } 58 | 59 | m := nrc.FindMachine(s.machineName) 60 | if m == nil { 61 | return "", "", fmt.Errorf("No credentials found in NetRCPath") 62 | } 63 | 64 | return m.Login, m.Password, nil 65 | } 66 | 67 | // ReadAuthnToken fetches the cached conjur access token. We only do this for OIDC 68 | // since we don't have access to the Conjur API key and this is the only credential we can save. 69 | func (s *NetrcStorageProvider) ReadAuthnToken() ([]byte, error) { 70 | _, tokenStr, err := s.ReadCredentials() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return []byte(tokenStr), nil 76 | } 77 | 78 | // StoreAuthnToken stores the conjur access token. We only do this for OIDC 79 | // since we don't have access to the Conjur API key and this is the only credential we can save. 80 | func (s *NetrcStorageProvider) StoreAuthnToken(token []byte) error { 81 | // We should be able to use an empty string for username, but unfortunately 82 | // this causes panics later on. Instead use a dummy value. 83 | return s.StoreCredentials("[oidc]", string(token)) 84 | } 85 | 86 | // PurgeCredentials purges credentials from the specified .netrc file 87 | func (s *NetrcStorageProvider) PurgeCredentials() error { 88 | // Remove cached credentials (username, api key) from .netrc 89 | nrc, err := netrc.ParseFile(s.netRCPath) 90 | if err != nil { 91 | // If the .netrc file doesn't exist, we don't need to do anything 92 | if errors.Is(err, os.ErrNotExist) { 93 | return nil 94 | } 95 | // Any other error should be returned 96 | return err 97 | } 98 | 99 | nrc.RemoveMachine(s.machineName) 100 | 101 | data, err := nrc.MarshalText() 102 | if err != nil { 103 | return err 104 | } 105 | 106 | return os.WriteFile(s.netRCPath, data, 0600) 107 | } 108 | 109 | func (s *NetrcStorageProvider) ensureNetrcFileExists() error { 110 | _, err := os.Stat(s.netRCPath) 111 | if err != nil { 112 | if errors.Is(err, os.ErrNotExist) { 113 | err = os.WriteFile(s.netRCPath, []byte{}, 0600) 114 | if err != nil { 115 | return err 116 | } 117 | } else { 118 | return err 119 | } 120 | } 121 | return nil 122 | } 123 | 124 | func ensureEndsWithNewline(data []byte) []byte { 125 | if data[len(data)-1] != byte('\n') { 126 | data = append(data, byte('\n')) 127 | } 128 | return data 129 | } 130 | -------------------------------------------------------------------------------- /conjurapi/storage/netrc_storage_provider_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type netrcTestConfig struct { 13 | ApplianceURL string 14 | NetRCPath string 15 | AuthnType string 16 | ServiceID string 17 | } 18 | 19 | func TestNetrcStorageProvider_StoreCredentials(t *testing.T) { 20 | config := setupNetrcConfig(t) 21 | 22 | t.Run("Creates file if it does not exist", func(t *testing.T) { 23 | os.Remove(config.NetRCPath) 24 | 25 | storage := setupNetrcStorage(config) 26 | err := storage.StoreCredentials("login", "apiKey") 27 | assert.NoError(t, err) 28 | 29 | contents, err := os.ReadFile(config.NetRCPath) 30 | assert.NoError(t, err) 31 | assert.Contains(t, string(contents), config.ApplianceURL+"/authn") 32 | assert.Contains(t, string(contents), "apiKey") 33 | }) 34 | 35 | t.Run("Creates machine if it does not exist", func(t *testing.T) { 36 | os.Remove(config.NetRCPath) 37 | _, err := os.Create(config.NetRCPath) 38 | assert.NoError(t, err) 39 | 40 | storage := setupNetrcStorage(config) 41 | err = storage.StoreCredentials("login", "apiKey") 42 | assert.NoError(t, err) 43 | 44 | contents, err := os.ReadFile(config.NetRCPath) 45 | assert.NoError(t, err) 46 | assert.Contains(t, string(contents), config.ApplianceURL+"/authn") 47 | assert.Contains(t, string(contents), "apiKey") 48 | }) 49 | 50 | t.Run("Updates machine if it exists", func(t *testing.T) { 51 | os.Remove(config.NetRCPath) 52 | initialContent := ` 53 | machine http://conjur/authn 54 | login admin 55 | password password` 56 | 57 | err := os.WriteFile(config.NetRCPath, []byte(initialContent), 0600) 58 | assert.NoError(t, err) 59 | 60 | storage := setupNetrcStorage(config) 61 | err = storage.StoreCredentials("login", "apiKey") 62 | assert.NoError(t, err) 63 | 64 | contents, err := os.ReadFile(config.NetRCPath) 65 | assert.NoError(t, err) 66 | assert.Contains(t, string(contents), config.ApplianceURL) 67 | assert.Contains(t, string(contents), "apiKey") 68 | }) 69 | } 70 | 71 | func TestNetrcStorageProvider_ReadCredentials(t *testing.T) { 72 | config := setupNetrcConfig(t) 73 | 74 | t.Run("Returns credentials from netrc", func(t *testing.T) { 75 | os.Remove(config.NetRCPath) 76 | 77 | initialContent := ` 78 | machine http://conjur/authn 79 | login admin 80 | password password` 81 | 82 | err := os.WriteFile(config.NetRCPath, []byte(initialContent), 0600) 83 | assert.NoError(t, err) 84 | 85 | storage := setupNetrcStorage(config) 86 | login, apiKey, err := storage.ReadCredentials() 87 | assert.NoError(t, err) 88 | assert.Equal(t, "admin", login) 89 | assert.Equal(t, "password", apiKey) 90 | }) 91 | 92 | t.Run("Returns error if file does not exist", func(t *testing.T) { 93 | os.Remove(config.NetRCPath) 94 | 95 | storage := setupNetrcStorage(config) 96 | login, apiKey, err := storage.ReadCredentials() 97 | assert.Error(t, err) 98 | assert.Equal(t, "", login) 99 | assert.Equal(t, "", apiKey) 100 | }) 101 | 102 | t.Run("Returns error if machine does not exist", func(t *testing.T) { 103 | os.Remove(config.NetRCPath) 104 | _, err := os.Create(config.NetRCPath) 105 | assert.NoError(t, err) 106 | 107 | storage := setupNetrcStorage(config) 108 | login, apiKey, err := storage.ReadCredentials() 109 | assert.Error(t, err) 110 | assert.Equal(t, "", login) 111 | assert.Equal(t, "", apiKey) 112 | }) 113 | } 114 | 115 | func TestNetrcStorageProvider_StoreAuthnToken(t *testing.T) { 116 | config := setupNetrcConfig(t) 117 | t.Run("Uses authn type in machine url", func(t *testing.T) { 118 | os.Remove(config.NetRCPath) 119 | 120 | oidcConfig := netrcTestConfig{ 121 | ApplianceURL: config.ApplianceURL, 122 | NetRCPath: config.NetRCPath, 123 | AuthnType: "oidc", 124 | ServiceID: "my-service", 125 | } 126 | 127 | storage := setupNetrcStorage(oidcConfig) 128 | err := storage.StoreAuthnToken([]byte("token-contents")) 129 | assert.NoError(t, err) 130 | 131 | contents, err := os.ReadFile(config.NetRCPath) 132 | assert.NoError(t, err) 133 | assert.Contains(t, string(contents), config.ApplianceURL+"/authn-oidc/my-service") 134 | assert.Contains(t, string(contents), "token-contents") 135 | }) 136 | } 137 | 138 | func TestNetrcStorageProvider_ReadAuthnToken(t *testing.T) { 139 | config := setupNetrcConfig(t) 140 | config.AuthnType = "oidc" 141 | config.ServiceID = "my-service" 142 | 143 | t.Run("Returns token cached in netrc", func(t *testing.T) { 144 | os.Remove(config.NetRCPath) 145 | 146 | initialContent := ` 147 | machine http://conjur/authn-oidc/my-service 148 | login [oidc] 149 | password token-contents` 150 | 151 | err := os.WriteFile(config.NetRCPath, []byte(initialContent), 0600) 152 | assert.NoError(t, err) 153 | 154 | storage := setupNetrcStorage(config) 155 | token, err := storage.ReadAuthnToken() 156 | assert.NoError(t, err) 157 | assert.NotNil(t, token) 158 | assert.Equal(t, "token-contents", string(token)) 159 | }) 160 | 161 | t.Run("Returns empty token if file does not exist", func(t *testing.T) { 162 | os.Remove(config.NetRCPath) 163 | 164 | storage := setupNetrcStorage(config) 165 | token, _ := storage.ReadAuthnToken() 166 | assert.Nil(t, token) 167 | }) 168 | } 169 | 170 | func TestNetrcStorageProvider_PurgeCredentials(t *testing.T) { 171 | config := setupNetrcConfig(t) 172 | 173 | t.Run("Removes credentials from netrc", func(t *testing.T) { 174 | os.Remove(config.NetRCPath) 175 | 176 | initialContent := ` 177 | machine http://conjur/authn 178 | login admin 179 | password password` 180 | 181 | err := os.WriteFile(config.NetRCPath, []byte(initialContent), 0600) 182 | assert.NoError(t, err) 183 | 184 | storage := setupNetrcStorage(config) 185 | err = storage.PurgeCredentials() 186 | assert.NoError(t, err) 187 | 188 | contents, err := os.ReadFile(config.NetRCPath) 189 | assert.NoError(t, err) 190 | assert.NotContains(t, string(contents), config.ApplianceURL) 191 | assert.NotContains(t, string(contents), "password") 192 | }) 193 | 194 | t.Run("Does not error if file does not exist", func(t *testing.T) { 195 | os.Remove(config.NetRCPath) 196 | 197 | storage := setupNetrcStorage(config) 198 | err := storage.PurgeCredentials() 199 | assert.NoError(t, err) 200 | }) 201 | 202 | t.Run("Does not error if machine does not exist", func(t *testing.T) { 203 | os.Remove(config.NetRCPath) 204 | _, err := os.Create(config.NetRCPath) 205 | assert.NoError(t, err) 206 | 207 | storage := setupNetrcStorage(config) 208 | err = storage.PurgeCredentials() 209 | assert.NoError(t, err) 210 | }) 211 | } 212 | 213 | func setupNetrcConfig(t *testing.T) netrcTestConfig { 214 | tempDir := t.TempDir() 215 | t.Cleanup(func() { 216 | os.RemoveAll(tempDir) 217 | }) 218 | return netrcTestConfig{ 219 | ApplianceURL: "http://conjur", 220 | NetRCPath: filepath.Join(tempDir, ".netrc"), 221 | } 222 | } 223 | 224 | func setupNetrcStorage(config netrcTestConfig) *NetrcStorageProvider { 225 | return NewNetrcStorageProvider( 226 | config.NetRCPath, 227 | getMachineName(config.ApplianceURL, config.AuthnType, config.ServiceID), 228 | ) 229 | } 230 | 231 | func getMachineName(applianceURL, authnType, serviceID string) string { 232 | if authnType != "" && authnType != "authn" { 233 | authnType := fmt.Sprintf("authn-%s", authnType) 234 | return fmt.Sprintf("%s/%s/%s", applianceURL, authnType, serviceID) 235 | } 236 | 237 | return applianceURL + "/authn" 238 | } 239 | -------------------------------------------------------------------------------- /conjurapi/storage_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cyberark/conjur-api-go/conjurapi/storage" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/zalando/go-keyring" 9 | ) 10 | 11 | func TestGetMachineName(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | config Config 15 | expected string 16 | }{ 17 | { 18 | name: "default authn", 19 | config: Config{ 20 | ApplianceURL: "https://conjur", 21 | }, 22 | expected: "https://conjur/authn", 23 | }, 24 | { 25 | name: "authn-oidc", 26 | config: Config{ 27 | ApplianceURL: "https://conjur", 28 | AuthnType: "oidc", 29 | ServiceID: "test-service", 30 | }, 31 | expected: "https://conjur/authn-oidc/test-service", 32 | }, 33 | } 34 | for _, tc := range testCases { 35 | t.Run(tc.name, func(t *testing.T) { 36 | machineName := getMachineName(tc.config) 37 | assert.Equal(t, tc.expected, machineName) 38 | }) 39 | } 40 | } 41 | 42 | func TestCreateStorageProvider(t *testing.T) { 43 | testCases := []struct { 44 | name string 45 | config Config 46 | action func() 47 | assert func(t *testing.T, storageProvider CredentialStorageProvider, err error) 48 | }{ 49 | { 50 | name: "default storage", 51 | config: Config{ 52 | ApplianceURL: "https://conjur", 53 | }, 54 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 55 | // Keyring shouldn't be avaialble by default in the container running the tests 56 | // Therefore it should default to netrc file storage 57 | assert.Nil(t, err) 58 | assert.NotNil(t, storageProvider) 59 | assert.IsType(t, &storage.NetrcStorageProvider{}, storageProvider) 60 | }, 61 | }, 62 | { 63 | name: "keyring storage when not available", 64 | config: Config{ 65 | ApplianceURL: "https://conjur", 66 | CredentialStorage: "keyring", 67 | }, 68 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 69 | assert.ErrorContains(t, err, "Keyring is not available") 70 | }, 71 | }, 72 | { 73 | name: "default storage with keyring available", 74 | config: Config{ 75 | ApplianceURL: "https://conjur", 76 | }, 77 | action: func() { 78 | // Enable a mock memory-based keyring storage 79 | keyring.MockInit() 80 | }, 81 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 82 | assert.Nil(t, err) 83 | assert.NotNil(t, storageProvider) 84 | assert.IsType(t, &storage.KeyringStorageProvider{}, storageProvider) 85 | }, 86 | }, 87 | { 88 | name: "keyring storage when available", 89 | config: Config{ 90 | ApplianceURL: "https://conjur", 91 | CredentialStorage: "keyring", 92 | }, 93 | action: func() { 94 | // Enable a mock memory-based keyring storage 95 | keyring.MockInit() 96 | }, 97 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 98 | assert.Nil(t, err) 99 | assert.NotNil(t, storageProvider) 100 | assert.IsType(t, &storage.KeyringStorageProvider{}, storageProvider) 101 | }, 102 | }, 103 | { 104 | name: "netrc storage", 105 | config: Config{ 106 | ApplianceURL: "https://conjur", 107 | CredentialStorage: "file", 108 | }, 109 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 110 | assert.Nil(t, err) 111 | assert.NotNil(t, storageProvider) 112 | assert.IsType(t, &storage.NetrcStorageProvider{}, storageProvider) 113 | }, 114 | }, 115 | { 116 | name: "no storage", 117 | config: Config{ 118 | ApplianceURL: "https://conjur", 119 | CredentialStorage: "none", 120 | }, 121 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 122 | assert.Nil(t, err) 123 | assert.Nil(t, storageProvider) 124 | }, 125 | }, 126 | { 127 | name: "invalid storage option", 128 | config: Config{ 129 | ApplianceURL: "https://conjur", 130 | CredentialStorage: "invalid", 131 | }, 132 | assert: func(t *testing.T, storageProvider CredentialStorageProvider, err error) { 133 | assert.ErrorContains(t, err, "Unknown credential storage type") 134 | }, 135 | }, 136 | } 137 | 138 | for _, tc := range testCases { 139 | t.Run(tc.name, func(t *testing.T) { 140 | if tc.action != nil { 141 | tc.action() 142 | } 143 | 144 | storage, err := createStorageProvider(tc.config) 145 | tc.assert(t, storage, err) 146 | }) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /conjurapi/utils_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/cyberark/conjur-api-go/conjurapi/authn" 9 | ) 10 | 11 | type TestUtils interface { 12 | Client() *Client 13 | Setup(policy string) (map[string]string, error) 14 | SetupWithAuthenticator(authnType string, authenticatorPolicy string, policy string) error 15 | PolicyBranch() string 16 | IDWithPath(id string) string 17 | AdminUser() string 18 | DefaultTestPolicy() string 19 | } 20 | 21 | type BaseTestUtils struct { 22 | client *Client 23 | } 24 | 25 | // We want a sub-path under 'data' to simplify compatibility with Conjur Cloud 26 | // where adding resources under 'root' is restricted 27 | func (b *BaseTestUtils) PolicyBranch() string { 28 | return "data/test" 29 | } 30 | 31 | func (b *BaseTestUtils) IDWithPath(id string) string { 32 | return b.PolicyBranch() + "/" + id 33 | } 34 | 35 | func (b *BaseTestUtils) Client() *Client { 36 | return b.client 37 | } 38 | 39 | type CloudTestUtils struct { 40 | BaseTestUtils 41 | } 42 | 43 | // Setup handles cleaning up resources and loading a test policy into the correct sub-branch (via replace) 44 | // It returns the created roles and their API keys as a map. 45 | func (u *CloudTestUtils) Setup(policy string) (map[string]string, error) { 46 | emptyTestBranch := fmt.Sprintf(` 47 | - !policy 48 | id: test 49 | owner: !user /%s`, u.AdminUser()) 50 | 51 | _, err := u.client.LoadPolicy( 52 | PolicyModePatch, // Conjur Cloud doesn't allow 'replace' on 'data' branch 53 | "data", 54 | strings.NewReader(emptyTestBranch), 55 | ) 56 | if err != nil { 57 | fmt.Println("Policy load error: ", err) 58 | } 59 | // Clear the database 60 | if _, err := u.client.LoadPolicy( 61 | PolicyModePut, 62 | "data/test", 63 | strings.NewReader(`--- []`), 64 | ); err != nil { 65 | fmt.Println("Policy load error: ", err) 66 | } 67 | roles, err := u.client.LoadPolicy( 68 | PolicyModePut, 69 | u.PolicyBranch(), 70 | strings.NewReader(policy), 71 | ) 72 | if err != nil { 73 | fmt.Println("Policy load error: ", err) 74 | } 75 | 76 | // Extract the last part of the role ID and the API key to return as a map 77 | keys := make(map[string]string) 78 | for _, role := range roles.CreatedRoles { 79 | keys[extractLogin(role.ID)] = role.APIKey 80 | } 81 | 82 | return keys, err 83 | } 84 | 85 | // SetupWithAuthenticator loads a test policy followed by an authenticator policy 86 | func (u *CloudTestUtils) SetupWithAuthenticator(authnType string, authenticatorPolicy string, policy string) error { 87 | _, err := u.Setup(policy) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // Cloud is preconfigured with empty authenticator policy branches 93 | authenticatorPath := fmt.Sprintf("conjur/authn-%s", authnType) 94 | 95 | _, err = u.client.LoadPolicy( 96 | PolicyModePost, 97 | authenticatorPath, 98 | strings.NewReader(authenticatorPolicy), 99 | ) 100 | 101 | return err 102 | } 103 | 104 | func (u *CloudTestUtils) AdminUser() string { 105 | return os.Getenv("CONJUR_AUTHN_LOGIN") 106 | } 107 | 108 | func (u *CloudTestUtils) DefaultTestPolicy() string { 109 | return fmt.Sprintf(` 110 | - !host 111 | id: bob 112 | owner: !user /%s 113 | annotations: 114 | authn/api-key: true 115 | - !host 116 | id: jimmy 117 | owner: !user /%s 118 | annotations: 119 | authn/api-key: true 120 | 121 | - !variable db-password 122 | - !variable db-password-2 123 | - !variable password 124 | 125 | - !permit 126 | role: !host bob 127 | privilege: [ execute ] 128 | resource: !variable db-password 129 | 130 | - !policy 131 | id: prod 132 | body: 133 | - !variable cluster-admin 134 | - !variable cluster-admin-password 135 | 136 | - !policy 137 | id: database 138 | body: 139 | - !variable username 140 | - !variable password 141 | `, u.AdminUser(), u.AdminUser()) 142 | } 143 | 144 | type DefaultTestUtils struct { 145 | BaseTestUtils 146 | } 147 | 148 | // Setup handles loading a test policy into the correct sub-branch (via replace) 149 | // It returns the created roles and their API keys as a map. 150 | func (u *DefaultTestUtils) Setup(policy string) (map[string]string, error) { 151 | // Clear the database 152 | if _, err := u.client.LoadPolicy( 153 | PolicyModePut, 154 | "root", 155 | strings.NewReader(`--- []`), 156 | ); err != nil { 157 | fmt.Println("Policy load error: ", err) 158 | } 159 | // Ensure we have a 'data/test' policy branch. 160 | emptyTestBranch := ` 161 | - !policy 162 | id: data 163 | body: 164 | - !policy test` 165 | 166 | _, err := u.client.LoadPolicy( 167 | PolicyModePut, 168 | "root", 169 | strings.NewReader(emptyTestBranch), 170 | ) 171 | if err != nil { 172 | fmt.Println("Policy load error: ", err) 173 | } 174 | 175 | roles, err := u.client.LoadPolicy( 176 | PolicyModePut, 177 | u.PolicyBranch(), 178 | strings.NewReader(policy), 179 | ) 180 | if err != nil { 181 | fmt.Println("Policy load error: ", err) 182 | } 183 | 184 | // Extract the last part of the role ID and the API key to return as a map 185 | keys := make(map[string]string) 186 | for _, role := range roles.CreatedRoles { 187 | keys[extractLogin(role.ID)] = role.APIKey 188 | } 189 | 190 | return keys, err 191 | } 192 | 193 | // SetupWithAuthenticator loads a test policy followed by an authenticator policy 194 | func (u *DefaultTestUtils) SetupWithAuthenticator(authnType string, authenticatorPolicy string, policy string) error { 195 | _, err := u.Setup(policy) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | authenticatorPath := fmt.Sprintf("conjur/authn-%s", authnType) 201 | emptyAuthenticatorBranch := fmt.Sprintf(` 202 | - !policy 203 | id: %s 204 | `, authenticatorPath) 205 | 206 | // Ensure the policy branch 'conjur/authn-' exists 207 | _, err = u.client.LoadPolicy( 208 | PolicyModePost, 209 | "root", 210 | strings.NewReader(emptyAuthenticatorBranch), 211 | ) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | _, err = u.client.LoadPolicy( 217 | PolicyModePost, 218 | authenticatorPath, 219 | strings.NewReader(authenticatorPolicy), 220 | ) 221 | 222 | return err 223 | } 224 | 225 | func (u *DefaultTestUtils) AdminUser() string { 226 | return "admin" 227 | } 228 | 229 | func (u *DefaultTestUtils) DefaultTestPolicy() string { 230 | return ` 231 | - !host bob 232 | - !host jimmy 233 | 234 | - !variable db-password 235 | - !variable db-password-2 236 | - !variable password 237 | 238 | - !permit 239 | role: !host bob 240 | privilege: [ execute ] 241 | resource: !variable db-password 242 | 243 | - !policy 244 | id: prod 245 | body: 246 | - !variable cluster-admin 247 | - !variable cluster-admin-password 248 | 249 | - !policy 250 | id: database 251 | body: 252 | - !variable username 253 | - !variable password 254 | ` 255 | } 256 | 257 | // Creates a set of test utils depending on which Conjur environment is being used. 258 | // 259 | // OSS/Enterprise - we assume that the env variables include CONJUR_AUTHN_LOGIN and CONJUR_AUTHN_API_KEY 260 | // were populated with the default admin user credentials during Conjur startup. 261 | // 262 | // Cloud - we assume that the env variables include CONJUR_AUTHN_LOGIN and CONJUR_AUTHN_TOKEN 263 | // retrieved during the CI tenant creation process. 264 | func NewTestUtils(config *Config) (TestUtils, error) { 265 | if config == nil { 266 | config = &Config{} 267 | } 268 | 269 | config.mergeEnv() 270 | 271 | if isConjurCloudURL(os.Getenv("CONJUR_APPLIANCE_URL")) { 272 | client, err := NewClientFromEnvironment(*config) 273 | if err != nil { 274 | return nil, fmt.Errorf("failed to create cloud client: %w", err) 275 | } 276 | return &CloudTestUtils{BaseTestUtils{client: client}}, nil 277 | } 278 | 279 | apiKey := os.Getenv("CONJUR_AUTHN_API_KEY") 280 | login := os.Getenv("CONJUR_AUTHN_LOGIN") 281 | client, err := NewClientFromKey(*config, authn.LoginPair{Login: login, APIKey: apiKey}) 282 | if err != nil { 283 | return nil, fmt.Errorf("failed to create default client: %w", err) 284 | } 285 | return &DefaultTestUtils{BaseTestUtils{client: client}}, nil 286 | } 287 | 288 | func extractLogin(fullyQualifiedRoleID string) string { 289 | // Remove the account/kind prefixes 290 | parts := strings.Split(fullyQualifiedRoleID, ":") 291 | roleID := parts[len(parts)-1] 292 | 293 | // Remove the policy path if it exists 294 | if strings.Contains(roleID, "/") { 295 | subParts := strings.Split(roleID, "/") 296 | roleID = subParts[len(subParts)-1] 297 | } 298 | 299 | return roleID 300 | } 301 | -------------------------------------------------------------------------------- /conjurapi/variable.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/cyberark/conjur-api-go/conjurapi/response" 11 | ) 12 | 13 | // RetrieveBatchSecrets fetches values for all variables in a slice using a 14 | // single API call 15 | // 16 | // The authenticated user must have execute privilege on all variables. 17 | func (c *Client) RetrieveBatchSecrets(variableIDs []string) (map[string][]byte, error) { 18 | jsonResponse, err := c.retrieveBatchSecrets(variableIDs, false) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | resolvedVariables := map[string][]byte{} 24 | for id, value := range jsonResponse { 25 | resolvedVariables[id] = []byte(value) 26 | } 27 | 28 | return resolvedVariables, nil 29 | } 30 | 31 | // RetrieveBatchSecretsSafe fetches values for all variables in a slice using a 32 | // single API call. This version of the method will automatically base64-encode 33 | // the secrets on the server side allowing the retrieval of binary values in 34 | // batch requests. Secrets are NOT base64 encoded in the returned map. 35 | // 36 | // The authenticated user must have execute privilege on all variables. 37 | func (c *Client) RetrieveBatchSecretsSafe(variableIDs []string) (map[string][]byte, error) { 38 | jsonResponse, err := c.retrieveBatchSecrets(variableIDs, true) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return decodeBase64Values(jsonResponse) 44 | } 45 | 46 | // RetrieveSecret fetches a secret from a variable. 47 | // 48 | // The authenticated user must have execute privilege on the variable. 49 | func (c *Client) RetrieveSecret(variableID string) ([]byte, error) { 50 | resp, err := c.retrieveSecret(variableID) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return response.DataResponse(resp) 56 | } 57 | 58 | // RetrieveSecretReader fetches a secret from a variable and returns it as a 59 | // data stream. 60 | // 61 | // The authenticated user must have execute privilege on the variable. 62 | func (c *Client) RetrieveSecretReader(variableID string) (io.ReadCloser, error) { 63 | resp, err := c.retrieveSecret(variableID) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return response.SecretDataResponse(resp) 69 | } 70 | 71 | // RetrieveSecretWithVersion fetches a specific version of a secret from a 72 | // variable. 73 | // 74 | // The authenticated user must have execute privilege on the variable. 75 | func (c *Client) RetrieveSecretWithVersion(variableID string, version int) ([]byte, error) { 76 | resp, err := c.retrieveSecretWithVersion(variableID, version) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return response.DataResponse(resp) 82 | } 83 | 84 | // RetrieveSecretWithVersionReader fetches a specific version of a secret from a 85 | // variable and returns it as a data stream. 86 | // 87 | // The authenticated user must have execute privilege on the variable. 88 | func (c *Client) RetrieveSecretWithVersionReader(variableID string, version int) (io.ReadCloser, error) { 89 | resp, err := c.retrieveSecretWithVersion(variableID, version) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | return response.SecretDataResponse(resp) 95 | } 96 | 97 | func (c *Client) retrieveBatchSecrets(variableIDs []string, base64Flag bool) (map[string]string, error) { 98 | req, err := c.RetrieveBatchSecretsRequest(variableIDs, base64Flag) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | resp, err := c.SubmitRequest(req) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | data, err := response.DataResponse(resp) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | if base64Flag && resp.Header.Get("Content-Encoding") != "base64" { 114 | return nil, errors.New( 115 | "Conjur response is not Base64-encoded. " + 116 | "The Conjur version may not be compatible with this function - " + 117 | "try using RetrieveBatchSecrets instead.") 118 | } 119 | 120 | jsonResponse := map[string]string{} 121 | err = json.Unmarshal(data, &jsonResponse) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | return jsonResponse, nil 127 | } 128 | 129 | func (c *Client) retrieveSecret(variableID string) (*http.Response, error) { 130 | req, err := c.RetrieveSecretRequest(variableID) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | return c.SubmitRequest(req) 136 | } 137 | 138 | func (c *Client) retrieveSecretWithVersion(variableID string, version int) (*http.Response, error) { 139 | req, err := c.RetrieveSecretWithVersionRequest(variableID, version) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | return c.SubmitRequest(req) 145 | } 146 | 147 | // AddSecret adds a secret value to a variable. 148 | // 149 | // The authenticated user must have update privilege on the variable. 150 | func (c *Client) AddSecret(variableID string, secretValue string) error { 151 | req, err := c.AddSecretRequest(variableID, secretValue) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | resp, err := c.SubmitRequest(req) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | return response.EmptyResponse(resp) 162 | } 163 | 164 | func decodeBase64Values(jsonResponse map[string]string) (map[string][]byte, error) { 165 | resolvedVariables := map[string][]byte{} 166 | for id, value := range jsonResponse { 167 | decodedValue, err := base64.StdEncoding.DecodeString(value) 168 | if err != nil { 169 | return nil, err 170 | } 171 | resolvedVariables[id] = decodedValue 172 | } 173 | return resolvedVariables, nil 174 | } 175 | -------------------------------------------------------------------------------- /conjurapi/variable_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/cyberark/conjur-api-go/conjurapi/authn" 10 | "github.com/cyberark/conjur-api-go/conjurapi/response" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestClient_RetrieveSecret(t *testing.T) { 16 | config := &Config{} 17 | config.mergeEnv() 18 | 19 | utils, err := NewTestUtils(config) 20 | assert.NoError(t, err) 21 | 22 | conjur := utils.Client() 23 | 24 | t.Run("On a populated secret", func(t *testing.T) { 25 | variableIdentifier := "existent-variable-with-defined-value" 26 | 27 | oldSecretValue := fmt.Sprintf("old-secret-value-%v", rand.Intn(123456)) 28 | secretValue := fmt.Sprintf("latest-secret-value-%v", rand.Intn(123456)) 29 | 30 | policy := fmt.Sprintf(` 31 | - !variable %s 32 | `, variableIdentifier) 33 | 34 | assert.NoError(t, err) 35 | 36 | conjur.LoadPolicy( 37 | PolicyModePut, 38 | utils.PolicyBranch(), 39 | strings.NewReader(policy), 40 | ) 41 | 42 | err = conjur.AddSecret(utils.PolicyBranch()+"/"+variableIdentifier, oldSecretValue) 43 | assert.NoError(t, err) 44 | 45 | err = conjur.AddSecret(utils.PolicyBranch()+"/"+variableIdentifier, secretValue) 46 | 47 | t.Run("Returns existent variable's defined value as a stream", func(t *testing.T) { 48 | secretResponse, err := conjur.RetrieveSecretReader(utils.PolicyBranch() + "/" + variableIdentifier) 49 | assert.NoError(t, err) 50 | 51 | obtainedSecretValue, err := ReadResponseBody(secretResponse) 52 | assert.NoError(t, err) 53 | 54 | assert.Equal(t, secretValue, string(obtainedSecretValue)) 55 | }) 56 | 57 | t.Run("Returns existent variable's defined value", func(t *testing.T) { 58 | obtainedSecretValue, err := conjur.RetrieveSecret(utils.IDWithPath(variableIdentifier)) 59 | assert.NoError(t, err) 60 | 61 | assert.Equal(t, secretValue, string(obtainedSecretValue)) 62 | }) 63 | 64 | t.Run("Handles a fully qualified variable id", func(t *testing.T) { 65 | obtainedSecretValue, err := conjur.RetrieveSecret("conjur:variable:" + utils.IDWithPath(variableIdentifier)) 66 | assert.NoError(t, err) 67 | 68 | assert.Equal(t, secretValue, string(obtainedSecretValue)) 69 | }) 70 | 71 | t.Run("Prepends the account name automatically", func(t *testing.T) { 72 | obtainedSecretValue, err := conjur.RetrieveSecret("variable:" + utils.IDWithPath(variableIdentifier)) 73 | assert.NoError(t, err) 74 | 75 | assert.Equal(t, secretValue, string(obtainedSecretValue)) 76 | }) 77 | 78 | t.Run("Returns correct variable when version specified", func(t *testing.T) { 79 | obtainedSecretValue, err := conjur.RetrieveSecretWithVersion(utils.IDWithPath(variableIdentifier), 1) 80 | assert.NoError(t, err) 81 | 82 | assert.Equal(t, oldSecretValue, string(obtainedSecretValue)) 83 | }) 84 | 85 | t.Run("Returns correct variable value when version specified defined as a stream", func(t *testing.T) { 86 | secretResponse, err := conjur.RetrieveSecretWithVersionReader(utils.IDWithPath(variableIdentifier), 1) 87 | assert.NoError(t, err) 88 | 89 | obtainedSecretValue, err := ReadResponseBody(secretResponse) 90 | assert.NoError(t, err) 91 | 92 | assert.Equal(t, oldSecretValue, string(obtainedSecretValue)) 93 | }) 94 | 95 | t.Run("Rejects an id from the wrong account", func(t *testing.T) { 96 | _, err := conjur.RetrieveSecret("foobar:variable:" + utils.IDWithPath(variableIdentifier)) 97 | 98 | conjurError := err.(*response.ConjurError) 99 | assert.Equal(t, 404, conjurError.Code) 100 | }) 101 | 102 | t.Run("Rejects an id with the wrong kind", func(t *testing.T) { 103 | _, err := conjur.RetrieveSecret("conjur:waffle:" + utils.IDWithPath(variableIdentifier)) 104 | 105 | conjurError := err.(*response.ConjurError) 106 | assert.Equal(t, 404, conjurError.Code) 107 | }) 108 | }) 109 | 110 | t.Run("On many populated secrets", func(t *testing.T) { 111 | variables := map[string]string{ 112 | "myapp-01": "these", 113 | "alice@devops": "are", 114 | "prod/aws/db-password": "all", 115 | "research+development": "secret", 116 | "sales&marketing": "strings!", 117 | "onemore": "{\"json\": \"object\"}", 118 | "a/ b /c": "somevalue", 119 | } 120 | binaryVariables := map[string]string{ 121 | "binary1": "test\xf0\xf1", 122 | "binary2": "tes\xf0t\xf1i\xf2ng", 123 | "nonBinary": "testing", 124 | } 125 | 126 | policy := "" 127 | for id := range variables { 128 | policy = fmt.Sprintf("%s- !variable %s\n", policy, id) 129 | } 130 | 131 | for id := range binaryVariables { 132 | policy = fmt.Sprintf("%s- !variable %s\n", policy, id) 133 | } 134 | 135 | conjur.LoadPolicy( 136 | PolicyModePut, 137 | utils.PolicyBranch(), 138 | strings.NewReader(policy), 139 | ) 140 | 141 | for id, value := range variables { 142 | err = conjur.AddSecret(utils.IDWithPath(id), value) 143 | assert.NoError(t, err) 144 | } 145 | 146 | for id, value := range binaryVariables { 147 | err = conjur.AddSecret(utils.IDWithPath(id), value) 148 | assert.NoError(t, err) 149 | } 150 | 151 | t.Run("Fetch many secrets in a single batch retrieval", func(t *testing.T) { 152 | variableIds := []string{} 153 | for id := range variables { 154 | variableIds = append(variableIds, utils.IDWithPath(id)) 155 | } 156 | 157 | secrets, err := conjur.RetrieveBatchSecrets(variableIds) 158 | assert.NoError(t, err) 159 | 160 | for id, value := range variables { 161 | fullyQualifiedID := fmt.Sprintf("%s:variable:%s", config.Account, utils.IDWithPath(id)) 162 | fetchedValue, ok := secrets[fullyQualifiedID] 163 | assert.True(t, ok) 164 | assert.Equal(t, value, string(fetchedValue)) 165 | } 166 | }) 167 | 168 | t.Run("Fetch binary secrets in a batch request", func(t *testing.T) { 169 | variableIds := []string{} 170 | for id := range binaryVariables { 171 | variableIds = append(variableIds, utils.IDWithPath(id)) 172 | } 173 | 174 | secrets, err := conjur.RetrieveBatchSecretsSafe(variableIds) 175 | assert.NoError(t, err) 176 | 177 | for id, value := range binaryVariables { 178 | fullyQualifiedID := fmt.Sprintf("%s:variable:%s", config.Account, utils.IDWithPath(id)) 179 | fetchedValue, ok := secrets[fullyQualifiedID] 180 | assert.True(t, ok) 181 | assert.Equal(t, value, string(fetchedValue)) 182 | } 183 | }) 184 | 185 | t.Run("Fail to fetch binary secrets in batch request", func(t *testing.T) { 186 | variableIds := []string{} 187 | for id := range binaryVariables { 188 | variableIds = append(variableIds, utils.IDWithPath(id)) 189 | } 190 | 191 | _, err := conjur.RetrieveBatchSecrets(variableIds) 192 | assert.Error(t, err) 193 | assert.Contains(t, err.Error(), "Issue encoding secret into JSON format") 194 | conjurError := err.(*response.ConjurError) 195 | assert.Equal(t, 406, conjurError.Code) 196 | }) 197 | }) 198 | 199 | t.Run("Returns 404 on existent variable with undefined value", func(t *testing.T) { 200 | variableIdentifier := "existent-variable-with-undefined-value" 201 | policy := fmt.Sprintf(` 202 | - !variable %s 203 | `, variableIdentifier) 204 | 205 | conjur.LoadPolicy( 206 | PolicyModePut, 207 | utils.PolicyBranch(), 208 | strings.NewReader(policy), 209 | ) 210 | 211 | _, err = conjur.RetrieveSecret(utils.IDWithPath(variableIdentifier)) 212 | 213 | assert.Error(t, err) 214 | assert.Contains(t, err.Error(), "CONJ00076E Variable conjur:variable:data/test/existent-variable-with-undefined-value is empty or not found") 215 | conjurError := err.(*response.ConjurError) 216 | assert.Equal(t, 404, conjurError.Code) 217 | assert.Equal(t, "not_found", conjurError.Details.Code) 218 | }) 219 | 220 | t.Run("Returns 404 on non-existent variable", func(t *testing.T) { 221 | 222 | _, err = conjur.RetrieveSecret(utils.IDWithPath("non-existent-variable")) 223 | 224 | assert.Error(t, err) 225 | assert.Contains(t, err.Error(), "CONJ00076E Variable conjur:variable:data/test/non-existent-variable is empty or not found") 226 | conjurError := err.(*response.ConjurError) 227 | assert.Equal(t, 404, conjurError.Code) 228 | assert.Equal(t, "not_found", conjurError.Details.Code) 229 | }) 230 | 231 | t.Run("Given configuration has invalid login credentials", func(t *testing.T) { 232 | // file deepcode ignore NoHardcodedCredentials/test: This is a test file 233 | login := "invalid-user" 234 | apiKey := "invalid-key" 235 | 236 | t.Run("Returns 401 and a user not found error", func(t *testing.T) { 237 | conjur, err := NewClientFromKey(*config, authn.LoginPair{Login: login, APIKey: apiKey}) 238 | assert.NoError(t, err) 239 | 240 | _, err = conjur.RetrieveSecret(utils.IDWithPath("existent-or-non-existent-variable")) 241 | 242 | assert.Error(t, err) 243 | conjurError := err.(*response.ConjurError) 244 | assert.Equal(t, 401, conjurError.Code) 245 | }) 246 | }) 247 | } 248 | func TestDecodeBase64Values(t *testing.T) { 249 | t.Run("happy path", func(t *testing.T) { 250 | jsonResponse := map[string]string{ 251 | "variable1": "SGVsbG8gd29ybGQ=", // "Hello world" 252 | "variable2": "c3VwZXJfc2VjcmV0X3ZhcmlhYmxl", // "super_secret_variable" 253 | } 254 | 255 | resolvedVariables, err := decodeBase64Values(jsonResponse) 256 | 257 | require.NoError(t, err) 258 | assert.Equal(t, []byte("Hello world"), resolvedVariables["variable1"]) 259 | assert.Equal(t, []byte("super_secret_variable"), resolvedVariables["variable2"]) 260 | }) 261 | 262 | t.Run("invalid base64", func(t *testing.T) { 263 | jsonResponse := map[string]string{ 264 | "variable1": "SGVsbG8gd29ybGQ=", // "Hello world" 265 | "variable2": "VGhpcyBpcyBhIG5lZWRlZCB2YWx1ZQ==", // "super_secret_variable" 266 | "variable3": "InvalidBase64Value", 267 | } 268 | 269 | _, err := decodeBase64Values(jsonResponse) 270 | assert.Error(t, err) 271 | assert.Contains(t, err.Error(), "illegal base64 data at input byte") 272 | // Ensure the values isn't included in the error message 273 | assert.NotContains(t, err.Error(), "InvalidBase64Value") 274 | }) 275 | } 276 | -------------------------------------------------------------------------------- /conjurapi/version.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "fmt" 5 | 6 | semver "github.com/Masterminds/semver/v3" 7 | ) 8 | 9 | // VerifyMinServerVersion checks if the server version is at least a certain version, using semantic versioning. 10 | func (c *Client) VerifyMinServerVersion(minVersion string) error { 11 | serverVersion, err := c.ServerVersion() 12 | if err != nil { 13 | return err 14 | } 15 | 16 | return validateMinVersion(serverVersion, minVersion) 17 | } 18 | 19 | // Validates that the actual version is at least the minimum version, using semantic versioning. 20 | func validateMinVersion(actualVersion string, minVersion string) error { 21 | conjurVersion, err := semver.NewVersion(actualVersion) 22 | if err != nil { 23 | return fmt.Errorf("failed to parse server version: %s", err) 24 | } 25 | 26 | minConjurVersion, err := semver.NewVersion(minVersion) 27 | if err != nil { 28 | return fmt.Errorf("failed to parse minimum version: %s", err) 29 | } 30 | 31 | // Ignore version suffixes (eg. 1.21.1-359) as we use them differently in the Conjur versioning scheme. 32 | // In SemVer, the suffix is considered a pre-release version, but in Conjur, it is used as a build version. 33 | simplifiedVersion, _ := conjurVersion.SetPrerelease("") 34 | 35 | if simplifiedVersion.LessThan(minConjurVersion) { 36 | return fmt.Errorf("Conjur version %s is less than the minimum required version %s", conjurVersion, minConjurVersion) 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /conjurapi/version_test.go: -------------------------------------------------------------------------------- 1 | package conjurapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestValidateMinVersion(t *testing.T) { 10 | tests := []struct { 11 | actualVersion string 12 | minVersion string 13 | expectedError string 14 | }{ 15 | {"1.0.0", "1.0.0", ""}, 16 | {"1.0.1", "1.0.0", ""}, 17 | {"1.1.0", "1.0.0", ""}, 18 | {"2.0.0", "1.0.0", ""}, 19 | {"1.0.0", "1.0.1", "Conjur version 1.0.0 is less than the minimum required version 1.0.1"}, 20 | {"1.0.0", "2.0.0", "Conjur version 1.0.0 is less than the minimum required version 2.0.0"}, 21 | {"invalid", "1.0.0", "failed to parse server version: Invalid Semantic Version"}, 22 | {"1.0.0", "invalid", "failed to parse minimum version: Invalid Semantic Version"}, 23 | {"1.21.1-359", "1.21.1", ""}, 24 | {"1.21.0-359", "1.21.1-359", "Conjur version 1.21.0-359 is less than the minimum required version 1.21.1-359"}, 25 | {"1.21.1-359", "1.21.0-359", ""}, 26 | } 27 | 28 | for _, test := range tests { 29 | err := validateMinVersion(test.actualVersion, test.minVersion) 30 | if test.expectedError == "" { 31 | assert.NoError(t, err) 32 | } else { 33 | assert.EqualError(t, err, test.expectedError) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:15 4 | environment: 5 | # To avoid the following error: 6 | # 7 | # Error: Database is uninitialized and superuser password is not 8 | # specified. You must specify POSTGRES_PASSWORD for the superuser. Use 9 | # "-e POSTGRES_PASSWORD=password" to set it in "docker run". 10 | # 11 | # You may also use POSTGRES_HOST_AUTH_METHOD=trust to allow all 12 | # connections without a password. This is *not* recommended. See 13 | # PostgreSQL documentation about "trust" 14 | POSTGRES_HOST_AUTH_METHOD: trust 15 | 16 | conjur: 17 | image: ${REGISTRY_URL:-docker.io}/cyberark/conjur:edge 18 | command: server -a conjur 19 | environment: 20 | DATABASE_URL: postgres://postgres@postgres/postgres 21 | CONJUR_DATA_KEY: 22 | RAILS_ENV: development 23 | # Enable dynamic secrets for the Issuers API 24 | CONJUR_FEATURE_DYNAMIC_SECRETS_ENABLED: true 25 | depends_on: 26 | - postgres 27 | 28 | test-1.23: 29 | build: 30 | context: . 31 | args: 32 | FROM_IMAGE: "golang:1.23" 33 | ports: 34 | - 8080 35 | depends_on: 36 | - conjur 37 | volumes: 38 | - ./output:/conjur-api-go/output 39 | environment: 40 | CONJUR_DATA_KEY: 41 | CONJUR_APPLIANCE_URL: http://conjur 42 | CONJUR_ACCOUNT: conjur 43 | CONJUR_AUTHN_LOGIN: admin 44 | CONJUR_AUTHN_API_KEY: 45 | GO_VERSION: 46 | 47 | test-1.24: 48 | build: 49 | context: . 50 | args: 51 | FROM_IMAGE: "golang:1.24" 52 | ports: 53 | - 8080 54 | depends_on: 55 | - conjur 56 | volumes: 57 | - ./output:/conjur-api-go/output 58 | environment: 59 | CONJUR_DATA_KEY: 60 | CONJUR_APPLIANCE_URL: http://conjur 61 | CONJUR_ACCOUNT: conjur 62 | CONJUR_AUTHN_LOGIN: admin 63 | CONJUR_AUTHN_API_KEY: 64 | GO_VERSION: 65 | 66 | dev: 67 | build: 68 | context: . 69 | args: 70 | FROM_IMAGE: "golang:1.24" 71 | ports: 72 | - 8080 73 | depends_on: 74 | - conjur 75 | volumes: 76 | - .:/conjur-api-go 77 | environment: 78 | CONJUR_DATA_KEY: 79 | CONJUR_APPLIANCE_URL: http://conjur 80 | CONJUR_ACCOUNT: conjur 81 | CONJUR_AUTHN_LOGIN: admin 82 | CONJUR_AUTHN_API_KEY: 83 | entrypoint: sleep 84 | command: infinity 85 | 86 | jwt-server: 87 | image: "ghcr.io/stackitcloud/fake-jwt-server:v0.1.1" 88 | ports: 89 | - 8008 90 | environment: 91 | ISSUER: "jwt-server" 92 | AUDIENCE: "conjur" 93 | SUBJECT: "test-workload" 94 | EMAIL: "workload@example.com" 95 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cyberark/conjur-api-go 2 | 3 | // This version has to be the lowest of the versions that we run tests with. Currently 4 | // we test with 1.23 and 1.24 (See Jenkinsfile) so this needs to be 1.23. 5 | go 1.23.7 6 | 7 | require ( 8 | github.com/Masterminds/semver/v3 v3.3.1 9 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d 10 | github.com/sirupsen/logrus v1.8.1 11 | github.com/stretchr/testify v1.9.0 12 | github.com/zalando/go-keyring v0.2.6 13 | gopkg.in/yaml.v2 v2.4.0 14 | ) 15 | 16 | require ( 17 | al.essio.dev/pkg/shellescape v1.5.1 // indirect 18 | github.com/danieljoos/wincred v1.2.2 // indirect 19 | github.com/davecgh/go-spew v1.1.1 // indirect 20 | github.com/godbus/dbus/v5 v5.1.0 // indirect 21 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 22 | github.com/pmezard/go-difflib v1.0.0 // indirect 23 | golang.org/x/sys v0.26.0 // indirect 24 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect 25 | gopkg.in/yaml.v3 v3.0.1 // indirect 26 | ) 27 | 28 | replace gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c => gopkg.in/yaml.v3 v3.0.1 29 | 30 | replace golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 => golang.org/x/sys v0.8.0 31 | 32 | replace golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c => golang.org/x/sys v0.8.0 33 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= 2 | al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 4 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 5 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= 6 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= 7 | github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 8 | github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 12 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 13 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 14 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 15 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 16 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 17 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 18 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 19 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 23 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 24 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 25 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 26 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 27 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 28 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 29 | github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 30 | github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 31 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 33 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= 36 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 38 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 39 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 40 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 41 | -------------------------------------------------------------------------------- /kics.config: -------------------------------------------------------------------------------- 1 | exclude-queries: 2 | - 965a08d7-ef86-4f14-8792-4a3b2098937e # Apt Get Install Pin Version Not Defined 3 | - fd54f200-402c-4333-a5a4-36ef6709af2f # Missing User Instruction 4 | - ce76b7d0-9e77-464d-b86f-c5c48e03e22d # Container Capabilities Unrestricted 5 | - 8c978947-0ff6-485c-b0c2-0bfca6026466 # Shared Volumes Between Containers 6 | - 610e266e-6c12-4bca-9925-1ed0cd29742b # Security Opt Not Set 7 | - b03a748a-542d-44f4-bb86-9199ab4fd2d5 # Healthcheck Instruction Missing 8 | - 698ed579-b239-4f8f-a388-baa4bcb13ef8 # Healthcheck Not Set 9 | - 451d79dc-0588-476a-ad03-3c7f0320abb3 # Container Traffic Not Bound To Host Interface 10 | - df746b39-6564-4fed-bf85-e9c44382303c # Apt Get Install Lists Were Not Deleted 11 | - 4f31dd9f-2cc3-4751-9b53-67e4af83dac0 # Host Namespace is Shared 12 | - ce14a68b-1668-41a0-ab7d-facd9f784742 # Networks Not Set 13 | --------------------------------------------------------------------------------