From 20c4cbd3d55451fe74e5c60c5ee6b4f7e5777971 Mon Sep 17 00:00:00 2001 From: Matthew Brace Date: Wed, 18 Jan 2023 14:40:09 -0600 Subject: [PATCH 001/665] Modify base for cloud pseudo-fork --- CHANGELOG.md | 4 ++ Dockerfile.test | 2 +- Jenkinsfile | 136 +----------------------------------------- build.sh | 20 +++---- ci/docker-compose.yml | 4 +- publish-images.sh | 94 ++--------------------------- 6 files changed, 24 insertions(+), 236 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fda9931260..a2dc67d3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.1-cloud] - 2022-01-13 +### Changed +- Remove auto-release options to allow for a pseudo-fork development on a branch + ## [1.19.2] - 2022-01-13 ### Fixed diff --git a/Dockerfile.test b/Dockerfile.test index a91f36550c..1244e85fa5 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,4 @@ ARG VERSION=latest -FROM conjur:${VERSION} +FROM conjur-cloud:${VERSION} RUN bundle --no-deployment --without '' diff --git a/Jenkinsfile b/Jenkinsfile index 5c8d83e4ba..b61f6eecd1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -45,37 +45,6 @@ These are defined in runConjurTests, and also include the one-offs gcp_authenticator */ -// Automated release, promotion and dependencies -properties([ - // Include the automated release parameters for the build - release.addParams(), - // Dependencies of the project that should trigger builds - dependencies(['cyberark/conjur-base-image', - 'cyberark/conjur-api-ruby', - 'conjurinc/debify']) -]) - -// Performs release promotion. No other stages will be run -if (params.MODE == "PROMOTE") { - release.promote(params.VERSION_TO_PROMOTE) { sourceVersion, targetVersion, assetDirectory -> - sh "docker pull registry.tld/cyberark/conjur:${sourceVersion}" - sh "docker tag registry.tld/cyberark/conjur:${sourceVersion} conjur:${sourceVersion}" - sh "docker pull registry.tld/conjur-ubi:${sourceVersion}" - sh "docker tag registry.tld/conjur-ubi:${sourceVersion} conjur-ubi:${sourceVersion}" - sh "summon -f ./secrets.yml ./publish-images.sh --promote --redhat --base-version=${sourceVersion} --version=${targetVersion}" - - // Trigger Conjurops build to push newly promoted releases of conjur to ConjurOps Staging - build( - job:'../conjurinc--conjurops/master', - parameters:[ - string(name: 'conjur_oss_source_image', value: "cyberark/conjur:${targetVersion}") - ], - wait: false - ) - } - return -} - pipeline { agent { label 'executor-v2' } @@ -117,26 +86,7 @@ pipeline { } - environment { - // Sets the MODE to the specified or autocalculated value as appropriate - MODE = release.canonicalizeMode() - } - stages { - // Aborts any builds triggered by another project that wouldn't include any changes - stage ("Skip build if triggering job didn't create a release") { - when { - expression { - MODE == "SKIP" - } - } - steps { - script { - currentBuild.result = 'ABORTED' - error("Aborting build because this build was triggered from upstream, but no release was built") - } - } - } // Generates a VERSION file based on the current build number and latest version in CHANGELOG.md stage('Validate Changelog and set version') { steps { @@ -145,27 +95,6 @@ pipeline { } } - stage('Fetch tags') { - steps { - withCredentials( - [ - usernameColonPassword( - credentialsId: 'conjur-jenkins-api', variable: 'GITCREDS' - ) - ] - ) { - sh ''' - git fetch --tags "$( - git remote get-url origin | - sed -e "s|https://|https://$GITCREDS@|" - )" - # print them out to make sure, can remove when this is robust - git tag - ''' - } - } - } - stage('Validate Changelog') { when { expression { params.RUN_ONLY == '' } @@ -248,22 +177,6 @@ pipeline { } } - // TODO: Add comments explaining which env vars are set here. - stage('Prepare For CodeClimate Coverage Report Submission') { - when { - expression { params.RUN_ONLY == '' } - } - steps { - catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { - script { - ccCoverage.dockerPrep() - sh 'mkdir -p coverage' - env.CODE_CLIMATE_PREPARED = "true" - } - } - } - } - // Run outside parallel block to avoid external pressure stage('RSpec - Standard agent tests') { steps { @@ -606,17 +519,6 @@ pipeline { } post { - success { - script { - if (env.BRANCH_NAME == 'master') { - build( - job:'../cyberark--secrets-provider-for-k8s/main', - wait: false - ) - } - } - } - always { script { @@ -685,42 +587,6 @@ pipeline { } } } // end stage: build and test conjur - - stage('Submit Coverage Report') { - when { - expression { - env.CODE_CLIMATE_PREPARED == "true" - } - } - steps{ - sh 'ci/submit-coverage' - } - } - - stage("Release Conjur images and packages") { - when { - expression { - MODE == "RELEASE" - } - } - steps { - release { billOfMaterialsDirectory, assetDirectory -> - // Publish docker images - sh './publish-images.sh --edge --dockerhub' - - // Create deb and rpm packages - sh 'echo "CONJUR_VERSION=5" >> debify.env' - sh './package.sh' - archiveArtifacts artifacts: '*.deb', fingerprint: true - archiveArtifacts artifacts: '*.rpm', fingerprint: true - sh "cp *.rpm ${assetDirectory}/." - sh "cp *.deb ${assetDirectory}/." - - // Publish deb and rpm packages - sh './publish.sh' - } - } - } } post { @@ -729,7 +595,7 @@ pipeline { // cleanupAndNotify(buildStatus, slackChannel, additionalMessage, ticket) cleanupAndNotify( currentBuild.currentResult, - '#conjur-core', + 'Team - Palm Tree', "${(params.NIGHTLY ? 'nightly' : '')}", true ) diff --git a/build.sh b/build.sh index 1531a5e5bd..e262ec0540 100755 --- a/build.sh +++ b/build.sh @@ -68,18 +68,18 @@ image_doesnt_exist() { [[ "$(docker images -q "$1" 2> /dev/null)" == "" ]] } -if image_doesnt_exist "conjur:$TAG"; then - echo "Building image conjur:$TAG" - docker build -t "conjur:$TAG" . - flatten "conjur:$TAG" +if image_doesnt_exist "conjur-cloud:$TAG"; then + echo "Building image conjur-cloud:$TAG" + docker build -t "conjur-cloud:$TAG" . + flatten "conjur-cloud:$TAG" fi -if image_doesnt_exist "conjur-test:$TAG"; then - echo "Building image conjur-test:$TAG container" - docker build --build-arg "VERSION=$TAG" -t "conjur-test:$TAG" -f Dockerfile.test . +if image_doesnt_exist "conjur-test-cloud:$TAG"; then + echo "Building image conjur-test-cloud:$TAG container" + docker build --build-arg "VERSION=$TAG" -t "conjur-test-cloud:$TAG" -f Dockerfile.test . fi -if image_doesnt_exist "conjur-ubi:$TAG"; then - echo "Building image conjur-ubi:$TAG container" - docker build --build-arg "VERSION=$TAG" -t "conjur-ubi:$TAG" -f Dockerfile.ubi . +if image_doesnt_exist "conjur-ubi-cloud:$TAG"; then + echo "Building image conjur-ubi-cloud:$TAG container" + docker build --build-arg "VERSION=$TAG" -t "conjur-ubi-cloud:$TAG" -f Dockerfile.ubi . fi diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 38f4c67de1..3a5e0c18c8 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -35,7 +35,7 @@ services: POSTGRES_HOST_AUTH_METHOD: trust conjur: - image: "conjur-test:${TAG}" + image: "conjur-test-cloud:${TAG}" environment: DATABASE_URL: postgres://postgres@pg/postgres CONJUR_ADMIN_PASSWORD: ADmin123!!!! @@ -68,7 +68,7 @@ services: - keycloak cucumber: - image: conjur-test:$TAG + image: conjur-test-cloud:$TAG entrypoint: bash working_dir: /src/conjur-server environment: diff --git a/publish-images.sh b/publish-images.sh index 69abfc7ee0..d3ffa76acc 100755 --- a/publish-images.sh +++ b/publish-images.sh @@ -20,11 +20,7 @@ function print_help() { echo " --base-version=VERSION: specify base image version number to use to apply tags to" } -PUBLISH_EDGE=false PUBLISH_INTERNAL=false -PROMOTE=false -REDHAT=false -DOCKERHUB=false VERSION=$( Date: Fri, 20 Jan 2023 12:52:32 -0600 Subject: [PATCH 002/665] Modify image name to indicate cloud --- Jenkinsfile | 8 ++++---- build.sh | 6 +++--- ci/docker-compose.yml | 4 ++-- ci/test_suites/authenticators_k8s/build_locally.sh | 8 ++++---- ci/test_suites/authenticators_k8s/entrypoint.sh | 14 +++++++------- publish-images.sh | 10 +++++----- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index b61f6eecd1..e27a4898e2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -156,22 +156,22 @@ pipeline { parallel { stage("Scan Docker Image for fixable issues") { steps { - scanAndReport("conjur:${tagWithSHA()}", "HIGH", false) + scanAndReport("conjur-cloud:${tagWithSHA()}", "HIGH", false) } } stage("Scan Docker image for total issues") { steps { - scanAndReport("conjur:${tagWithSHA()}", "NONE", true) + scanAndReport("conjur-cloud:${tagWithSHA()}", "NONE", true) } } stage("Scan UBI-based Docker Image for fixable issues") { steps { - scanAndReport("conjur-ubi:${tagWithSHA()}", "HIGH", false) + scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "HIGH", false) } } stage("Scan UBI-based Docker image for total issues") { steps { - scanAndReport("conjur-ubi:${tagWithSHA()}", "NONE", true) + scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "NONE", true) } } } diff --git a/build.sh b/build.sh index e262ec0540..c3e97c4892 100755 --- a/build.sh +++ b/build.sh @@ -74,9 +74,9 @@ if image_doesnt_exist "conjur-cloud:$TAG"; then flatten "conjur-cloud:$TAG" fi -if image_doesnt_exist "conjur-test-cloud:$TAG"; then - echo "Building image conjur-test-cloud:$TAG container" - docker build --build-arg "VERSION=$TAG" -t "conjur-test-cloud:$TAG" -f Dockerfile.test . +if image_doesnt_exist "conjur-test:$TAG"; then + echo "Building image conjur-test:$TAG container" + docker build --build-arg "VERSION=$TAG" -t "conjur-test:$TAG" -f Dockerfile.test . fi if image_doesnt_exist "conjur-ubi-cloud:$TAG"; then diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 3a5e0c18c8..38f4c67de1 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -35,7 +35,7 @@ services: POSTGRES_HOST_AUTH_METHOD: trust conjur: - image: "conjur-test-cloud:${TAG}" + image: "conjur-test:${TAG}" environment: DATABASE_URL: postgres://postgres@pg/postgres CONJUR_ADMIN_PASSWORD: ADmin123!!!! @@ -68,7 +68,7 @@ services: - keycloak cucumber: - image: conjur-test-cloud:$TAG + image: conjur-test:$TAG entrypoint: bash working_dir: /src/conjur-server environment: diff --git a/ci/test_suites/authenticators_k8s/build_locally.sh b/ci/test_suites/authenticators_k8s/build_locally.sh index e51d589ec2..0144e1bcca 100755 --- a/ci/test_suites/authenticators_k8s/build_locally.sh +++ b/ci/test_suites/authenticators_k8s/build_locally.sh @@ -28,8 +28,8 @@ cd "$(git rev-parse --show-toplevel)" || exit TAG="$(git rev-parse --short=8 HEAD)" export TAG="$TAG" -docker build --no-cache -t "conjur:$TAG" . -copy_cert "conjur:$TAG" "$sni_cert" -docker build --no-cache -t "registry.tld/conjur:$TAG" . -copy_cert "registry.tld/conjur:$TAG" "$sni_cert" +docker build --no-cache -t "conjur-cloud:$TAG" . +copy_cert "conjur-cloud:$TAG" "$sni_cert" +docker build --no-cache -t "registry.tld/conjur-cloud:$TAG" . +copy_cert "registry.tld/conjur-cloud:$TAG" "$sni_cert" docker build --no-cache --build-arg "VERSION=$TAG" -t "registry.tld/conjur-test:$TAG" -f Dockerfile.test . diff --git a/ci/test_suites/authenticators_k8s/entrypoint.sh b/ci/test_suites/authenticators_k8s/entrypoint.sh index 05b8d149a7..0e3e2be6bf 100755 --- a/ci/test_suites/authenticators_k8s/entrypoint.sh +++ b/ci/test_suites/authenticators_k8s/entrypoint.sh @@ -49,7 +49,7 @@ function setupTestEnvironment() { export PLATFORM - export CONJUR_AUTHN_K8S_TAG="${DOCKER_REGISTRY_PATH}/conjur:authn-k8s-$CONJUR_AUTHN_K8S_TEST_NAMESPACE" + export CONJUR_AUTHN_K8S_TAG="${DOCKER_REGISTRY_PATH}/conjur-cloud:authn-k8s-$CONJUR_AUTHN_K8S_TEST_NAMESPACE" export CONJUR_TEST_AUTHN_K8S_TAG="${DOCKER_REGISTRY_PATH}/conjur-test:authn-k8s-$CONJUR_AUTHN_K8S_TEST_NAMESPACE" export CONJUR_AUTHN_K8S_TESTER_TAG="${DOCKER_REGISTRY_PATH}/authn-k8s-tester:$CONJUR_AUTHN_K8S_TEST_NAMESPACE" @@ -101,10 +101,10 @@ function buildDockerImages() { # If the Conjur images aren't present, attempt to pull them from the registry. # If we can't pull them from the registry, see if we have local images we can # tag appropriately. - if ! docker image inspect "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" > /dev/null 2>&1; then - docker pull "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" || \ - docker image inspect "conjur:$conjur_version" > /dev/null && \ - docker tag "conjur:$conjur_version" "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" + if ! docker image inspect "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" > /dev/null 2>&1; then + docker pull "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" || \ + docker image inspect "conjur-cloud:$conjur_version" > /dev/null && \ + docker tag "conjur-cloud:$conjur_version" "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" fi if ! docker image inspect "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" > /dev/null 2>&1; then docker pull "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" || \ @@ -112,10 +112,10 @@ function buildDockerImages() { docker tag "conjur-test:$conjur_version" "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" fi - add_sni_cert_to_image "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" + add_sni_cert_to_image "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" add_sni_cert_to_image "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" - docker tag "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" "$CONJUR_AUTHN_K8S_TAG" + docker tag "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" "$CONJUR_AUTHN_K8S_TAG" # cukes will be run from this image docker tag "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" "$CONJUR_TEST_AUTHN_K8S_TAG" diff --git a/publish-images.sh b/publish-images.sh index d3ffa76acc..3498a6abed 100755 --- a/publish-images.sh +++ b/publish-images.sh @@ -47,7 +47,7 @@ for arg in "$@"; do esac done -LOCAL_IMAGE="conjur:${LOCAL_TAG}" +LOCAL_IMAGE="conjur-cloud:${LOCAL_TAG}" # Normalize version number in the case of '+' included VERSION="$(echo -n "${VERSION}" | tr "+" "_")" @@ -57,11 +57,11 @@ if [[ "${PUBLISH_INTERNAL}" = true ]]; then echo "Pushing ${LOCAL_TAG} tagged images to registry.tld..." # Always push SHA versioned images internally tag_and_push "${VERSION}-${LOCAL_TAG}" "${LOCAL_IMAGE}" "registry.tld/conjur-cloud" - tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-test:${LOCAL_TAG}" "registry.tld/conjur-test-cloud" - tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-ubi:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" + tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-test:${LOCAL_TAG}" "registry.tld/conjur-test" + tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-ubi-cloud:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" # Push SHA only tagged images to our internal registry tag_and_push "${LOCAL_TAG}" "${LOCAL_IMAGE}" "registry.tld/conjur-cloud" - tag_and_push "${LOCAL_TAG}" "conjur-test:${LOCAL_TAG}" "registry.tld/conjur-test-cloud" - tag_and_push "${LOCAL_TAG}" "conjur-ubi:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" + tag_and_push "${LOCAL_TAG}" "conjur-test:${LOCAL_TAG}" "registry.tld/conjur-test" + tag_and_push "${LOCAL_TAG}" "conjur-ubi-cloud:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" fi From 4e53a08f702a68af2b8e2b8ab7bf2d797feb613e Mon Sep 17 00:00:00 2001 From: Matthew Brace Date: Fri, 20 Jan 2023 15:43:47 -0600 Subject: [PATCH 003/665] Adjust to do full builds on conjur-cloud branch --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index e27a4898e2..4e249bbc95 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -713,7 +713,7 @@ def runConjurTests(run_only_str) { } def defaultCucumberFilterTags(env) { - if(env.BRANCH_NAME == 'master' || env.TAG_NAME?.trim()) { + if(env.BRANCH_NAME == 'master' || env.BRANCH_NAME == 'conjur-cloud' || env.TAG_NAME?.trim()) { // If this is a master or tag build, we want to run all of the tests. So // we use an empty filter string. return '' From 9f41997f1d7a3908d898eed1c95feaf85cbfd8ad Mon Sep 17 00:00:00 2001 From: nofarvered Date: Mon, 20 Feb 2023 13:11:19 +0200 Subject: [PATCH 004/665] Comment out k8s+ldap in jenkins file --- Jenkinsfile | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4e249bbc95..ef82de4608 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -532,9 +532,9 @@ pipeline { archiveFiles('coverage/.resultset*.json') archiveFiles('coverage/coverage.json') archiveFiles('coverage/codeclimate.json') - archiveFiles( - 'ci/test_suites/authenticators_k8s/output/simplecov-resultset-authnk8s-gke.json' - ) +// archiveFiles( +// 'ci/test_suites/authenticators_k8s/output/simplecov-resultset-authnk8s-gke.json' +// ) archiveFiles('cucumber/*/*.*') publishHTML([ @@ -645,16 +645,16 @@ def runConjurTests(run_only_str) { sh 'ci/test authenticators_status' } ], - "authenticators_k8s": [ - "K8s Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_k8s' - } - ], - "authenticators_ldap": [ - "LDAP Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_ldap' - } - ], +// "authenticators_k8s": [ +// "K8s Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_k8s' +// } +// ], +// "authenticators_ldap": [ +// "LDAP Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_ldap' +// } +// ], "authenticators_oidc": [ "OIDC Authenticator - ${env.STAGE_NAME}": { sh 'summon -f ./ci/test_suites/authenticators_oidc/secrets.yml -e ci ci/test authenticators_oidc' From fab7e62563209a5043321d748762e658f7cf64f5 Mon Sep 17 00:00:00 2001 From: acarmel Date: Thu, 2 Feb 2023 11:06:28 +0200 Subject: [PATCH 005/665] Edge new endpoint --- app/controllers/edge_controller.rb | 62 ++++++++++++++++++++++++++++++ config/routes.rb | 2 + 2 files changed, 64 insertions(+) create mode 100644 app/controllers/edge_controller.rb diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb new file mode 100644 index 0000000000..b4cc595300 --- /dev/null +++ b/app/controllers/edge_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class EdgeController < RestController + + def all_secrets + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + begin + #scope = Resource.visible_to(current_user).search(options) + #scope = Resource.visible_to(current_user).search + offset = options[:offset] + limit = options[:limit] + scope = Resource.where(:resource_id.like("conjur:variable:data%")) + scope = scope.order(:resource_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + results = [] + variables = scope.eager(:permissions).eager(:secrets).all + variables.each do |variable| + variableToReturn = {} + variableToReturn[:id] = variable[:resource_id] + variableToReturn[:owner] = variable[:owner_id] + variableToReturn[:permissions] = variable.permissions.select{|h| h[:privilege].eql?('execute')} + unless variable.last_secret.nil? + variableToReturn[:version] = variable.last_secret.version + variableToReturn[:value] = variable.last_secret.value + end + results << variableToReturn + end + render(json: results) + end + + def all_hosts + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + offset = options[:offset] + limit = options[:limit] + scope = Role.where(:role_id.like("conjur:host:data%")) + scope = scope.order(:role_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + results = [] + hosts = scope.eager(:credentials).all + hosts.each do |host| + hostToReturn = {} + hostToReturn[:id] = host[:role_id] + hostToReturn[:api_key] =host.api_key + hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} + results << hostToReturn + end + render(json: results) + end +end diff --git a/config/routes.rb b/config/routes.rb index e1f4db4a66..5edc21eaf4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,6 +75,8 @@ def matches?(request) get "/secrets/:account/:kind/*identifier" => 'secrets#show' post "/secrets/:account/:kind/*identifier" => 'secrets#create' get "/secrets" => 'secrets#batch' + get "/edge/secrets/:account" => 'edge#all_secrets' + get "/edge/hosts/:account" => 'edge#all_hosts' put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' From 3cf102e64f0f8ec7c39cddd42536f2467e0ca81a Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Tue, 14 Feb 2023 23:05:58 +0200 Subject: [PATCH 006/665] ONYX-32182: Secret Retrieval endpoint - productization --- CHANGELOG.md | 4 + app/controllers/edge_controller.rb | 117 ++++-- cucumber/api/features/edge.feature | 362 ++++++++++++++++++ .../features/step_definitions/user_steps.rb | 2 +- 4 files changed, 453 insertions(+), 32 deletions(-) create mode 100644 cucumber/api/features/edge.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index a2dc67d3c5..1dc7cdea9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.2-cloud] - 2023-03-06 +### Added +- Edge host endpoint and secret endpoint + ## [0.0.1-cloud] - 2022-01-13 ### Changed - Remove auto-release options to allow for a pseudo-fork development on a branch diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index b4cc595300..d2935d4019 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -7,11 +7,11 @@ def all_secrets options = params.permit(*allowed_params) .slice(*allowed_params).to_h.symbolize_keys begin - #scope = Resource.visible_to(current_user).search(options) - #scope = Resource.visible_to(current_user).search + verify_edge_host(options) offset = options[:offset] limit = options[:limit] - scope = Resource.where(:resource_id.like("conjur:variable:data%")) + validate_scope(limit, offset) + scope = Resource.where(:resource_id.like(options[:account]+":variable:data/%")) scope = scope.order(:resource_id).limit( (limit || 1000).to_i, (offset || 0).to_i @@ -21,42 +21,97 @@ def all_secrets rescue ArgumentError => e raise ApplicationController::UnprocessableEntity, e.message end - results = [] - variables = scope.eager(:permissions).eager(:secrets).all - variables.each do |variable| - variableToReturn = {} - variableToReturn[:id] = variable[:resource_id] - variableToReturn[:owner] = variable[:owner_id] - variableToReturn[:permissions] = variable.permissions.select{|h| h[:privilege].eql?('execute')} - unless variable.last_secret.nil? - variableToReturn[:version] = variable.last_secret.version - variableToReturn[:value] = variable.last_secret.value + + if params[:count] == 'true' + results = { count: scope.count('*'.lit) } + render(json: results) + else + results = [] + variables = scope.eager(:permissions).eager(:secrets).all + variables.each do |variable| + variableToReturn = {} + variableToReturn[:id] = variable[:resource_id] + variableToReturn[:owner] = variable[:owner_id] + variableToReturn[:permissions] = variable.permissions.select{|h| h[:privilege].eql?('execute')} + unless variable.last_secret.nil? + variableToReturn[:version] = variable.last_secret.version + variableToReturn[:value] = variable.last_secret.value + end + results << variableToReturn end - results << variableToReturn + render(json: {"secrets":results}) end - render(json: results) end def all_hosts allowed_params = %i[account limit offset] options = params.permit(*allowed_params) .slice(*allowed_params).to_h.symbolize_keys - offset = options[:offset] - limit = options[:limit] - scope = Role.where(:role_id.like("conjur:host:data%")) - scope = scope.order(:role_id).limit( - (limit || 1000).to_i, - (offset || 0).to_i - ) - results = [] - hosts = scope.eager(:credentials).all - hosts.each do |host| - hostToReturn = {} - hostToReturn[:id] = host[:role_id] - hostToReturn[:api_key] =host.api_key - hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} - results << hostToReturn + begin + verify_edge_host(options) + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + scope = Role.where(:role_id.like(options[:account]+":host:data/%")) + scope = scope.order(:role_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + if params[:count] == 'true' + results = { count: scope.count('*'.lit) } + render(json: results) + else + results = [] + hosts = scope.eager(:credentials).all + hosts.each do |host| + hostToReturn = {} + hostToReturn[:id] = host[:role_id] + #salt = OpenSSL::Random.random_bytes(32) + #hostToReturn[:api_key] = Base64.encode64(hmac_api_key(host, salt)) + hostToReturn[:api_key] = host.api_key + #hostToReturn[:salt] = Base64.encode64(salt) + hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} + results << hostToReturn + end + render(json: {"hosts": results}) end - render(json: results) + end + + private + + def validate_scope(limit, offset) + if offset || limit + # 'limit' must be an integer greater than 0 and less than 2000 if given + if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000 ) + raise ArgumentError, "'limit' contains an invalid value. 'limit' must be a positive integer and less than 2000" + end + # 'offset' must be an integer greater than or equal to 0 if given + if offset && (!numeric?(offset) || offset.to_i.negative?) + raise ArgumentError, "'offset' contains an invalid value. 'offset' must be an integer greater than or equal to 0." + end + end + end + + def verify_edge_host(options) + raise Forbidden unless current_user.kind == 'host' + raise Forbidden unless current_user.role_id.include? "host:edge/edge" + role = Role[options[:account] + ':group:edge/edge-admins'] + raise Forbidden unless role && role.ancestor_of?(current_user) + end + + def hmac_api_key(host, salt) + pass = host.api_key + iter = 20 + key_len = 16 + OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") + end + + def numeric? val + val == val.to_i.to_s end end diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature new file mode 100644 index 0000000000..91189a4f10 --- /dev/null +++ b/cucumber/api/features/edge.feature @@ -0,0 +1,362 @@ +@api +Feature: Fetching secrets from edge endpoint + + Background: + Given I create a new user "some_user" + And I have host "data/some_host1" + And I have host "data/some_host2" + And I have host "data/some_host3" + And I have host "data/some_host4" + And I have host "data/some_host5" + And I have host "other_host1" + And I have host "database/other_host2" + And I have a "variable" resource called "other_sec" + And I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-admins + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + + - !grant + role: !group edge/edge-admins + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + + - !policy + id: data + body: + - !variable secret1 + - !variable secret2 + - !variable secret3 + - !variable secret4 + - !variable secret5 + - !variable secret6 + """ + And I add the secret value "s1" to the resource "cucumber:variable:data/secret1" + And I add the secret value "s2" to the resource "cucumber:variable:data/secret2" + And I add the secret value "s3" to the resource "cucumber:variable:data/secret3" + And I add the secret value "s4" to the resource "cucumber:variable:data/secret4" + And I add the secret value "s5" to the resource "cucumber:variable:data/secret5" + And I log out + + # Secrets + ######### + + @acceptance + Scenario: Fetching all secrets with edge host return 200 OK with json results + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s1", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret2", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s2", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret3", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s3", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret4", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s4", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret5", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s5", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret6", + "owner": "cucumber:policy:data", + "permissions": [] + } + ]} + """ + + @negative @acceptance + Scenario: Fetching secrets with non edge host return 403 error + + Given I login as "some_user" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 403 + + + @acceptance + Scenario: Fetching secrets by batch with edge host return right json every batch call + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s1", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret2", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s2", + "version": 1 + } + ]} + """ + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret3", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s3", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret4", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s4", + "version": 1 + } + ]} + """ + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 1000 + offset: 4 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret5", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s5", + "version": 1 + }, + { + "id": "cucumber:variable:data/secret6", + "owner": "cucumber:policy:data", + "permissions": [] + } + ]} + """ + + @acceptance + Scenario: Fetching secrets by batch with edge host return right number of results + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 2 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 10 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 4 entries + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 6 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 4 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 2 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 6 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 6 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2000 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 6 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 0 + """ + Then the HTTP response status code is 422 + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2001 + """ + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching secrets count + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/secrets/cucumber?count=true" + Then I receive a count of 6 + + # Hosts + ####### + + @acceptance + Scenario: Fetching hosts with edge host return 200 OK + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 200 + And the JSON response at "hosts" should have 5 entries + And the JSON response should not have "database" + And the JSON response should not have "other_host" + + @acceptance + Scenario: Fetching hosts with parameters + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 10 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 3 entries + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" with parameters: + """ + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 3 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 5 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2000 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 0 + """ + Then the HTTP response status code is 422 + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2001 + """ + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching hosts count + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/hosts/cucumber?count=true" + Then I receive a count of 5 + + + @negative @acceptance + Scenario: Fetching hosts with non edge host return 403 + + Given I login as "some_user" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 diff --git a/cucumber/api/features/step_definitions/user_steps.rb b/cucumber/api/features/step_definitions/user_steps.rb index 7fcbeb26d8..086b101908 100644 --- a/cucumber/api/features/step_definitions/user_steps.rb +++ b/cucumber/api/features/step_definitions/user_steps.rb @@ -35,7 +35,7 @@ Given("I login as {string}") do |login| if host?(login) - loginid = login.split('/')[1] + loginid = login.slice(login.index('/')+1, login.length) roleid = (login.include?(":") ? login : "cucumber:host:#{loginid}") else roleid = (login.include?(":") ? login : "cucumber:user:#{login}") From 1512e2a6fec729b710db6346fd8041002f51bd68 Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Mon, 6 Mar 2023 17:57:59 +0200 Subject: [PATCH 007/665] ONYX-34602: Change edge group name --- CHANGELOG.md | 4 ++++ app/controllers/edge_controller.rb | 2 +- cucumber/api/features/edge.feature | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc7cdea9b..daeaaf9c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.3-cloud] - 2023-03-06 +### Changed +- Change edge group name + ## [0.0.2-cloud] - 2023-03-06 ### Added - Edge host endpoint and secret endpoint diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index d2935d4019..393a43f60d 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -100,7 +100,7 @@ def validate_scope(limit, offset) def verify_edge_host(options) raise Forbidden unless current_user.kind == 'host' raise Forbidden unless current_user.role_id.include? "host:edge/edge" - role = Role[options[:account] + ':group:edge/edge-admins'] + role = Role[options[:account] + ':group:edge/edge-hosts'] raise Forbidden unless role && role.ancestor_of?(current_user) end diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 91189a4f10..baa1905aea 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -17,7 +17,7 @@ Feature: Fetching secrets from edge endpoint - !policy id: edge body: - - !group edge-admins + - !group edge-hosts - !policy id: edge-abcd1234567890 body: @@ -27,7 +27,7 @@ Feature: Fetching secrets from edge endpoint authn/api-key: true - !grant - role: !group edge/edge-admins + role: !group edge/edge-hosts members: - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 From 8b5fa9b4978aaba76040d0351e3ea5f14500ab73 Mon Sep 17 00:00:00 2001 From: evgenys Date: Mon, 13 Mar 2023 23:03:03 +0200 Subject: [PATCH 008/665] Update code owners to Jenia --- .github/CODEOWNERS | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7219cbfa0d..45071fd1f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1 @@ -* @cyberark/conjur-core-team @conjurinc/conjur-core-team @conjurdemos/conjur-core-team - -# Changes to .trivyignore require Security Architect approval -.trivyignore @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects - -# Changes to .codeclimate.yml require Quality Architect approval -.codeclimate.yml @cyberark/quality-architects @conjurinc/quality-architects @conjurdemos/quality-architects - -# Changes to SECURITY.md require Security Architect approval -SECURITY.md @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects +* @jeniaSakirko From e15ba985316e75a5197403fa583a17222e54e468 Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Sun, 12 Mar 2023 17:37:00 +0200 Subject: [PATCH 009/665] ONYX-0000: Change edge api count parameter logic --- CHANGELOG.md | 4 +++ app/controllers/edge_controller.rb | 40 ++++++++++++++++++------------ cucumber/api/features/edge.feature | 14 +++++++++++ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db9faa0174..3460f1f3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.4-cloud] - 2023-03-12 +### Changed +- Change count=true not to consider limit and sum all + ## [0.0.3-cloud] - 2023-03-06 ### Changed - Change edge group name diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 393a43f60d..0c0a206256 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -8,14 +8,18 @@ def all_secrets .slice(*allowed_params).to_h.symbolize_keys begin verify_edge_host(options) - offset = options[:offset] - limit = options[:limit] - validate_scope(limit, offset) scope = Resource.where(:resource_id.like(options[:account]+":variable:data/%")) - scope = scope.order(:resource_id).limit( - (limit || 1000).to_i, - (offset || 0).to_i - ) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + scope = scope.order(:resource_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + end rescue ApplicationController::Forbidden raise rescue ArgumentError => e @@ -23,7 +27,7 @@ def all_secrets end if params[:count] == 'true' - results = { count: scope.count('*'.lit) } + results = { count: sumItems } render(json: results) else results = [] @@ -49,21 +53,25 @@ def all_hosts .slice(*allowed_params).to_h.symbolize_keys begin verify_edge_host(options) - offset = options[:offset] - limit = options[:limit] - validate_scope(limit, offset) scope = Role.where(:role_id.like(options[:account]+":host:data/%")) - scope = scope.order(:role_id).limit( - (limit || 1000).to_i, - (offset || 0).to_i - ) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + scope = scope.order(:role_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + end rescue ApplicationController::Forbidden raise rescue ArgumentError => e raise ApplicationController::UnprocessableEntity, e.message end if params[:count] == 'true' - results = { count: scope.count('*'.lit) } + results = { count: sumItems } render(json: results) else results = [] diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index baa1905aea..7e87e12ad2 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -267,6 +267,13 @@ Feature: Fetching secrets from edge endpoint When I successfully GET "/edge/secrets/cucumber?count=true" Then I receive a count of 6 + @acceptance + Scenario: Fetching secrets count with limit has no effect + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/secrets/cucumber?count=true&limit=2&offset=0" + Then I receive a count of 6 + # Hosts ####### @@ -347,6 +354,13 @@ Feature: Fetching secrets from edge endpoint When I successfully GET "/edge/hosts/cucumber?count=true" Then I receive a count of 5 + @acceptance + Scenario: Fetching hosts count with limit has no effect + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/hosts/cucumber?count=true&limit=2&offset=0" + Then I receive a count of 5 + @negative @acceptance Scenario: Fetching hosts with non edge host return 403 From 751688120f0658c6a6eadf4d5fb047cf287f78ce Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Tue, 14 Mar 2023 11:07:09 +0200 Subject: [PATCH 010/665] edge smoke tests --- cucumber/api/features/edge.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 7e87e12ad2..097bc8ed3c 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -51,7 +51,7 @@ Feature: Fetching secrets from edge endpoint # Secrets ######### - @acceptance + @acceptance @smoke Scenario: Fetching all secrets with edge host return 200 OK with json results Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" @@ -277,7 +277,7 @@ Feature: Fetching secrets from edge endpoint # Hosts ####### - @acceptance + @acceptance @smoke Scenario: Fetching hosts with edge host return 200 OK Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" From 159c38cab962d0de580b36819a4d284f01d194fd Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Sun, 12 Mar 2023 16:23:19 +0200 Subject: [PATCH 011/665] add get slosilo key endpoint --- app/controllers/edge_controller.rb | 29 ++++++++++- config/routes.rb | 1 + cucumber/api/features/edge.feature | 30 ++++++++++++ spec/controllers/edge_controller_spec.rb | 61 ++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 spec/controllers/edge_controller_spec.rb diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 0c0a206256..c681d96bd2 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -2,6 +2,30 @@ class EdgeController < RestController + def slosilo_keys + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + rescue ApplicationController::Forbidden + raise + end + account = options[:account] + key_id = "authn:" + account + + key = Slosilo[key_id] + if key.nil? + raise RecordNotFound, "No Slosilo key in DB" + end + + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + variable_to_return = {} + variable_to_return[:privateKey] = private_key + variable_to_return[:fingerprint] = fingerprint + render(json: {"slosiloKeys":[variable_to_return]}) + end + def all_secrets allowed_params = %i[account limit offset] options = params.permit(*allowed_params) @@ -106,10 +130,11 @@ def validate_scope(limit, offset) end def verify_edge_host(options) + raise Forbidden unless %w[conjur cucumber rspec].include?(options[:account]) raise Forbidden unless current_user.kind == 'host' - raise Forbidden unless current_user.role_id.include? "host:edge/edge" + raise Forbidden unless current_user.role_id.include?("host:edge/edge") role = Role[options[:account] + ':group:edge/edge-hosts'] - raise Forbidden unless role && role.ancestor_of?(current_user) + raise Forbidden unless role&.ancestor_of?(current_user) end def hmac_api_key(host, salt) diff --git a/config/routes.rb b/config/routes.rb index 5edc21eaf4..39544ffa69 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,6 +77,7 @@ def matches?(request) get "/secrets" => 'secrets#batch' get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' + get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 097bc8ed3c..874759357a 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -48,6 +48,36 @@ Feature: Fetching secrets from edge endpoint And I add the secret value "s5" to the resource "cucumber:variable:data/secret5" And I log out + # Slosilo key + ######### + @acceptance + Scenario: Fetching key with edge host return 200 OK with json result + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 200 + And the JSON at "slosiloKeys" should have 1 entries + And the JSON should have "slosiloKeys/0/fingerprint" + And the JSON at "slosiloKeys/0/fingerprint" should be a string + And the JSON should have "slosiloKeys/0/privateKey" + And the JSON at "slosiloKeys/0/privateKey" should be a string + + @negative @acceptance + Scenario: Fetching hosts with non edge host return 403 + Given I login as "some_user" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + #test wrong account name + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/slosilo_keys/cucumber2" + Then the HTTP response status code is 403 + + # Secrets ######### diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb new file mode 100644 index 0000000000..1831457b00 --- /dev/null +++ b/spec/controllers/edge_controller_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EdgeController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge"} + + before do + Slosilo["authn:#{account}"] ||= Slosilo::Key.new + @current_user = Role.find_or_create(role_id: host_id) + end + + let(:update_slosilo_keys_url) do + "/edge/slosilo_keys/#{account}" + end + + let(:token_auth_header) do + bearer_token = Slosilo["authn:#{account}"].signed_token(@current_user.login) + token_auth_str = + "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" + { 'HTTP_AUTHORIZATION' => token_auth_str } + end + + context "slosilo keys in DB" do + it "Slosilo keys equals to key in DB, Host and Role are correct" do + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("200") + + #get the Slosilo key from DB + key = Slosilo["authn:#{account}"] + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + + expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}]} + response_json = JSON.parse(response.body) + expect(response_json).to include(expected) + end + + it "Host is Edge but no Role exists at all" do + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("403") + end + + it "Host is Edge but the host is member in wrong role" do + #add edge-hosts to edge2/edge-hosts group + Role.create(role_id: "#{account}:group:edge2/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge2/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("403") + end + end +end \ No newline at end of file From 9dcc04a88d87923a849c986b419d80e7e6a3caf5 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 15 Mar 2023 13:13:12 +0200 Subject: [PATCH 012/665] add get slosilo key to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3460f1f3c0..2a2d98421b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.5-cloud] - 2023-03-15 +### Changed +- Add get SlosiloKey api + ## [0.0.4-cloud] - 2023-03-12 ### Changed - Change count=true not to consider limit and sum all From fa5edfb32f8cc340a15eaa174849b370e9603f2c Mon Sep 17 00:00:00 2001 From: evgenys Date: Thu, 16 Mar 2023 11:19:11 +0200 Subject: [PATCH 013/665] Add conjur cloud team as codeowners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 45071fd1f9..54cef097e6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @jeniaSakirko +* @jeniaSakirko @cyberark/ConjurCloud From 26c5c963018643067c81d4705fad90eaf6279887 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 12:07:10 +0000 Subject: [PATCH 014/665] Bump rack from 2.2.6.3 to 2.2.6.4 Bumps [rack](https://github.com/rack/rack) from 2.2.6.3 to 2.2.6.4. - [Release notes](https://github.com/rack/rack/releases) - [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md) - [Commits](https://github.com/rack/rack/compare/v2.2.6.3...v2.2.6.4) --- updated-dependencies: - dependency-name: rack dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- CHANGELOG.md | 2 ++ Gemfile.lock | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2d98421b..282ba13d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [cyberark/conjur#2729](https://github.com/cyberark/conjur/pull/2729) - Upgraded rack to v2.2.6.3 to resolve CVE-2023-27530 [cyberark/conjur#2739](https://github.com/cyberark/conjur/pull/2739) +- Upgraded rack to v2.2.6.4 to resolve CVE-2023-27539 + [cyberark/conjur#2750](https://github.com/cyberark/conjur/pull/2750) ## [1.19.2] - 2022-01-13 diff --git a/Gemfile.lock b/Gemfile.lock index 118b70c026..e3dac5d981 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -321,7 +321,7 @@ GEM puma (5.6.4) nio4r (~> 2.0) racc (1.6.2) - rack (2.2.6.3) + rack (2.2.6.4) rack-oauth2 (1.19.0) activesupport attr_required From 24980d33bd4f47d75c523d7d0297abef5c42f3e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 01:28:22 +0000 Subject: [PATCH 015/665] Bump activesupport from 7.0.4.2 to 7.0.4.3 in /docs Bumps [activesupport](https://github.com/rails/rails) from 7.0.4.2 to 7.0.4.3. - [Release notes](https://github.com/rails/rails/releases) - [Changelog](https://github.com/rails/rails/blob/v7.0.4.3/activesupport/CHANGELOG.md) - [Commits](https://github.com/rails/rails/compare/v7.0.4.2...v7.0.4.3) --- updated-dependencies: - dependency-name: activesupport dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docs/Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index bd24f47ad4..45958c5a5e 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - activesupport (7.0.4.2) + activesupport (7.0.4.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -15,7 +15,7 @@ GEM coffee-script-source (1.11.1) colorator (1.1.0) commonmarker (0.23.8) - concurrent-ruby (1.2.0) + concurrent-ruby (1.2.2) dnsruby (1.61.9) simpleidn (~> 0.1) em-websocket (0.5.3) @@ -212,7 +212,7 @@ GEM jekyll (>= 3.5, < 5.0) jekyll-feed (~> 0.9) jekyll-seo-tag (~> 2.1) - minitest (5.17.0) + minitest (5.18.0) nokogiri (1.14.2) mini_portile2 (~> 2.8.0) racc (~> 1.4) From cb192d780cd37754eb86aaaf613b5140191ae564 Mon Sep 17 00:00:00 2001 From: evgenys Date: Sun, 19 Mar 2023 17:01:24 +0200 Subject: [PATCH 016/665] Update changelog for 0.0.6 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 282ba13d6a..2ac7349ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.6-cloud] - 2023-03-19 +### Security +- Updated github-pages version in docs/Gemfile to allow upgrading activesupport + to v7.0.4.2 to resolve CVE-2022-22796 + [cyberark/conjur#2729](https://github.com/cyberark/conjur/pull/2729) +- Upgraded rack to v2.2.6.3 to resolve CVE-2023-27530 + [cyberark/conjur#2739](https://github.com/cyberark/conjur/pull/2739) +- Upgraded rack to v2.2.6.4 to resolve CVE-2023-27539 + [cyberark/conjur#2750](https://github.com/cyberark/conjur/pull/2750) + ## [0.0.5-cloud] - 2023-03-15 ### Changed - Add get SlosiloKey api From 19aecd5e93dd0fff9258379fe339ac5cdd63c34f Mon Sep 17 00:00:00 2001 From: evgenys Date: Mon, 27 Mar 2023 18:33:46 +0300 Subject: [PATCH 017/665] Update changelog with 0.0.7-cloud --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 840ca18080..2d35bd3cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.7-cloud] - 2023-03-27 +### Changed +- Merge from master 2023-03-27 to 2023-03-26 + ## [0.0.6-cloud] - 2023-03-19 ### Security - Updated github-pages version in docs/Gemfile to allow upgrading activesupport From 4179a7aca01a3d978dd96fb5fb2a4239d34e289e Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 3 Apr 2023 16:14:08 +0300 Subject: [PATCH 018/665] add logs to edge endpoint --- app/controllers/edge_controller.rb | 44 ++++++++++++++++++++++++++---- app/domain/errors.rb | 5 ++++ app/domain/logs.rb | 10 +++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index c681d96bd2..991156ea46 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -3,6 +3,7 @@ class EdgeController < RestController def slosilo_keys + logger.info(LogMessages::Conjur::EndpointRequested.new("slosilo_keys")) allowed_params = %i[account] options = params.permit(*allowed_params).to_h.symbolize_keys begin @@ -23,10 +24,13 @@ def slosilo_keys variable_to_return = {} variable_to_return[:privateKey] = private_key variable_to_return[:fingerprint] = fingerprint + logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("slosilo_keys")) render(json: {"slosiloKeys":[variable_to_return]}) end def all_secrets + logger.info(LogMessages::Conjur::EndpointRequested.new("all_secrets")) + allowed_params = %i[account limit offset] options = params.permit(*allowed_params) .slice(*allowed_params).to_h.symbolize_keys @@ -52,6 +56,7 @@ def all_secrets if params[:count] == 'true' results = { count: sumItems } + logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_secrets:count")) render(json: results) else results = [] @@ -67,11 +72,14 @@ def all_secrets end results << variableToReturn end + logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_secrets")) render(json: {"secrets":results}) end end def all_hosts + logger.info(LogMessages::Conjur::EndpointRequested.new("all_hosts")) + allowed_params = %i[account limit offset] options = params.permit(*allowed_params) .slice(*allowed_params).to_h.symbolize_keys @@ -96,6 +104,7 @@ def all_hosts end if params[:count] == 'true' results = { count: sumItems } + logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_hosts:count")) render(json: results) else results = [] @@ -110,6 +119,7 @@ def all_hosts hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} results << hostToReturn end + logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_hosts")) render(json: {"hosts": results}) end end @@ -130,13 +140,37 @@ def validate_scope(limit, offset) end def verify_edge_host(options) - raise Forbidden unless %w[conjur cucumber rspec].include?(options[:account]) - raise Forbidden unless current_user.kind == 'host' - raise Forbidden unless current_user.role_id.include?("host:edge/edge") - role = Role[options[:account] + ':group:edge/edge-hosts'] - raise Forbidden unless role&.ancestor_of?(current_user) + msg = "" + raise_excep = false + + if %w[conjur cucumber rspec].exclude?(options[:account]) + raise_excep = true + msg = "Account is: #{options[:account]}. Should be one of the following: [conjur cucumber rspec]" + elsif current_user.kind != 'host' + raise_excep = true + msg = "User kind is: #{current_user.kind}. Should be: 'host'" + elsif current_user.role_id.exclude?("host:edge/edge") + raise_excep = true + msg = "Role is: #{current_user.role_id}. Should include: 'host:edge/edge'" + else + role = Role[options[:account] + ':group:edge/edge-hosts'] + unless role&.ancestor_of?(current_user) + raise_excep = true + msg = "Curren user is: #{current_user}. should be member of #{role}" + end + end + + if raise_excep + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + msg + ) + ) + raise Forbidden + end end + def hmac_api_key(host, salt) pass = host.api_key iter = 20 diff --git a/app/domain/errors.rb b/app/domain/errors.rb index eb5ef49bd6..2bab51bf8f 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -65,6 +65,11 @@ module Authorization code: "CONJ00125E" ) + EndpointNotVisibleToRole = ::Util::TrackableErrorClass.new( + msg: "The requested endpoint is forbidden. Reason: '{0}'", + code: "CONJ00151E" + ) + AccessToResourceIsForbiddenForRole = ::Util::TrackableErrorClass.new( msg: "Role '{0-role}' does not have permissions to access the requested resource '{1-resource}'", code: "CONJ00122E" diff --git a/app/domain/logs.rb b/app/domain/logs.rb index a49d897f2a..8407b4fc9b 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -24,6 +24,16 @@ module Conjur code: "CONJ00038I" ) + EndpointRequested = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is called", + code: "CONJ00152" + ) + + EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully", + code: "CONJ00153" + ) + end module Authentication From c49d97ccb79e3f0e2ffe778cb3485d39447d3dab Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Tue, 18 Apr 2023 16:48:26 +0300 Subject: [PATCH 019/665] add edge-hosts endpoint --- CHANGELOG.md | 4 + app/controllers/concerns/host_verificator.rb | 33 ++++++++ app/controllers/edge_controller.rb | 16 ++-- app/controllers/hosts_controller.rb | 34 ++++++++ app/domain/logs.rb | 10 ++- config/routes.rb | 1 + cucumber/api/features/hosts.feature | 85 ++++++++++++++++++++ spec/controllers/hosts_controller_spec.rb | 74 +++++++++++++++++ 8 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 app/controllers/concerns/host_verificator.rb create mode 100644 app/controllers/hosts_controller.rb create mode 100644 cucumber/api/features/hosts.feature create mode 100644 spec/controllers/hosts_controller_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d97858b78..9fac26df98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.8-cloud] - 2023-04-30 +### Added +- New edge-hosts endpoints for edge + ## [0.0.7-cloud] - 2023-03-27 ### Changed - Merge from master 2023-03-27 to 2023-03-26 diff --git a/app/controllers/concerns/host_verificator.rb b/app/controllers/concerns/host_verificator.rb new file mode 100644 index 0000000000..db117c71b0 --- /dev/null +++ b/app/controllers/concerns/host_verificator.rb @@ -0,0 +1,33 @@ + +module HostValidator + extend ActiveSupport::Concern + def validate_conjur_account(account) + if %w[conjur cucumber rspec].exclude?(account) + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Account is: #{account}. Should be one of the following: [conjur cucumber rspec]" + ) + ) + raise ApplicationController::Forbidden + end + end + + def is_role_member_of_group(account, role_id, group_name) + role = Role[account + group_name] + unless role&.ancestor_of?(role = Role[role_id]) + return false + end + return true + end + + def validate_conjur_admin_group(account) + unless is_role_member_of_group(account, current_user.id, ':group:Conjur_Cloud_Admins') + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Current user role is: #{current_user.id}. should be member of: \"group:Conjur_Cloud_Admins\"" + ) + ) + raise ApplicationController::Forbidden + end + end +end diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 991156ea46..dc2b295928 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -3,7 +3,7 @@ class EdgeController < RestController def slosilo_keys - logger.info(LogMessages::Conjur::EndpointRequested.new("slosilo_keys")) + logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) allowed_params = %i[account] options = params.permit(*allowed_params).to_h.symbolize_keys begin @@ -24,12 +24,12 @@ def slosilo_keys variable_to_return = {} variable_to_return[:privateKey] = private_key variable_to_return[:fingerprint] = fingerprint - logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("slosilo_keys")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("slosilo_keys")) render(json: {"slosiloKeys":[variable_to_return]}) end def all_secrets - logger.info(LogMessages::Conjur::EndpointRequested.new("all_secrets")) + logger.info(LogMessages::Endpoints::EndpointRequested.new("all_secrets")) allowed_params = %i[account limit offset] options = params.permit(*allowed_params) @@ -56,7 +56,7 @@ def all_secrets if params[:count] == 'true' results = { count: sumItems } - logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_secrets:count")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets:count")) render(json: results) else results = [] @@ -72,13 +72,13 @@ def all_secrets end results << variableToReturn end - logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_secrets")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets")) render(json: {"secrets":results}) end end def all_hosts - logger.info(LogMessages::Conjur::EndpointRequested.new("all_hosts")) + logger.info(LogMessages::Endpoints::EndpointRequested.new("all_hosts")) allowed_params = %i[account limit offset] options = params.permit(*allowed_params) @@ -104,7 +104,7 @@ def all_hosts end if params[:count] == 'true' results = { count: sumItems } - logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_hosts:count")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts:count")) render(json: results) else results = [] @@ -119,7 +119,7 @@ def all_hosts hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} results << hostToReturn end - logger.info(LogMessages::Conjur::EndpointFinishedSuccessfully.new("all_hosts")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts")) render(json: {"hosts": results}) end end diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb new file mode 100644 index 0000000000..0dee2f1b8e --- /dev/null +++ b/app/controllers/hosts_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class HostsController < RestController + include HostValidator + def edge_hosts + logger.info(LogMessages::Endpoints::EndpointRequestedByUser.new("edge-hosts", Role.username_from_roleid(current_user.role_id))) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + begin + _verify_host(options) + rescue ApplicationController::Forbidden + raise + end + results = [] + hosts = Role.where(:role_id.like(options[:account]+":host:edge/%")) + hosts.each do |host| + id = host[:role_id] + next unless is_role_member_of_group(options[:account], id, ":group:edge/edge-hosts") + host_name = id.split('/').last + host_to_return = {} + host_to_return[:id] = id + host_to_return[:name] = host_name + results << host_to_return + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge-hosts")) + render(json:{"hosts": results}) + end + + def _verify_host(options) + account = options[:account] + validate_conjur_account(account) + validate_conjur_admin_group(account) + end +end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 8407b4fc9b..c6a26f92c2 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -23,17 +23,23 @@ module Conjur msg: "OpenSSL FIPS mode set to {0}", code: "CONJ00038I" ) + end + module Endpoints EndpointRequested = ::Util::TrackableLogMessageClass.new( msg: "{0} endpoint is called", code: "CONJ00152" ) - EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( - msg: "{0} endpoint is finished successfully", + EndpointRequestedByUser = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is called by {1} user", code: "CONJ00153" ) + EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully", + code: "CONJ00154" + ) end module Authentication diff --git a/config/routes.rb b/config/routes.rb index 39544ffa69..cd72b2d1da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,6 +78,7 @@ def matches?(request) get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' + get "/edge/edge-hosts/:account" => 'hosts#edge_hosts' put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' diff --git a/cucumber/api/features/hosts.feature b/cucumber/api/features/hosts.feature new file mode 100644 index 0000000000..303b5cb781 --- /dev/null +++ b/cucumber/api/features/hosts.feature @@ -0,0 +1,85 @@ +@api +Feature: Fetching edge host + Background: + Given I am the super-user + And I create a new user "alice" + And I create a new user "bob" + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !group Conjur_Cloud_Admins + - !grant + role: !group Conjur_Cloud_Admins + member: !user alice + + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + - !host + id: edge-host-abcd1234567894 + annotations: + authn/api-key: true + - !host + id: edge-host-abcd1234567891 + annotations: + authn/api-key: true + - !host + id: edge-host-abcd1234567893 + annotations: + authn/api-key: true + + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + - !host edge/edge-abcd1234567890/edge-host-abcd1234567891 + # Create data tree + - !policy + id: data + owner: !group Conjur_Cloud_Admins + body: + - !group Conjur_Cloud_Admins + - !host not-edge-host + - !grant + role: !group data/Conjur_Cloud_Admins + member: !user bob + """ + + @acceptance @smoke + Scenario: Fetching edge hosts when 2 edge hosts exists + Given I login as "alice" + When I GET "/edge/edge-hosts/cucumber" + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + Then the JSON at "hosts" should be: + """ + [ + { + "id": "cucumber:host:edge/edge-abcd1234567890/edge-host-abcd1234567890", + "name": "edge-host-abcd1234567890" + }, + { + "id": "cucumber:host:edge/edge-abcd1234567890/edge-host-abcd1234567891", + "name": "edge-host-abcd1234567891" + } + ] + """ + + @negative @acceptance + Scenario: Fetching edge host without group permission + Given I login as "bob" + When I GET "/edge/edge-hosts/cucumber" + Then the HTTP response status code is 403 + + @negative @acceptance + Scenario: Fetching edge host wrong account + Given I login as "alice" + When I GET "/edge/edge-hosts/cucumber1" + Then the HTTP response status code is 403 diff --git a/spec/controllers/hosts_controller_spec.rb b/spec/controllers/hosts_controller_spec.rb new file mode 100644 index 0000000000..e1e5705df8 --- /dev/null +++ b/spec/controllers/hosts_controller_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe HostsController, :type => :request do + let(:account) { "rspec" } + let(:user_id) {"#{account}:user:admin"} + let(:host_name) {"edge-host-6d50922eedee3fa58b8f20f675fc11a3"} + let(:id) {"edge/#{host_name}/#{host_name}"} + let(:admins_group) {"Conjur_Cloud_Admins"} + let(:edge_hosts_group) {"edge/edge-hosts"} + + before do + Slosilo["authn:#{account}"] ||= Slosilo::Key.new + @current_user = Role.find_or_create(role_id: user_id) + end + + let(:token_auth_header) do + bearer_token = Slosilo["authn:#{account}"].signed_token(@current_user.login) + token_auth_str = + "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" + { 'HTTP_AUTHORIZATION' => token_auth_str } + end + + context "Edge-host api" do + include_context "create host" + let(:edge_host) { create_host(id) } + + it "User in wrong group (rspec)" do + # add user to Conjur_Cloud_Admins group + group_name = "rspec" + Role.create(role_id: "#{account}:group:#{group_name}") + RoleMembership.create(role_id: "#{account}:group:#{group_name}", member_id: user_id) + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:#{edge_hosts_group}") + RoleMembership.create(role_id: "#{account}:group:#{edge_hosts_group}", member_id: edge_host.role_id) + + get("/edge/edge-hosts/#{account}", env: token_auth_header) + expect(response.code).to eq("403") + end + it "User in wrong group (cucumber)" do + # add user to Conjur_Cloud_Admins group + group_name = "cucumber" + Role.create(role_id: "#{account}:group:#{group_name}") + RoleMembership.create(role_id: "#{account}:group:#{group_name}", member_id: user_id) + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:#{edge_hosts_group}") + RoleMembership.create(role_id: "#{account}:group:#{edge_hosts_group}", member_id: edge_host.role_id) + + get("/edge/edge-hosts/#{account}", env: token_auth_header) + expect(response.code).to eq("403") + end + it "Edge host in wrong group" do + # add user to Conjur_Cloud_Admins group + Role.create(role_id: "#{account}:group:#{admins_group}") + RoleMembership.create(role_id: "#{account}:group:#{admins_group}", member_id: user_id) + #add edge-hosts to edge/edge-host group + group_name = "edge/edge-host" + Role.create(role_id: "#{account}:group:#{group_name}") + RoleMembership.create(role_id: "#{account}:group:#{group_name}", member_id: edge_host.role_id) + + get("/edge/edge-hosts/#{account}", env: token_auth_header) + expect(response.code).to eq("200") + expect(JSON.parse(response.body)).to eq({"hosts"=>[]}) + end + it "No edge hosts at all" do + # add user to Conjur_Cloud_Admins group + Role.create(role_id: "#{account}:group:#{admins_group}") + RoleMembership.create(role_id: "#{account}:group:#{admins_group}", member_id: user_id) + + get("/edge/edge-hosts/#{account}", env: token_auth_header) + expect(response.code).to eq("200") + expect(JSON.parse(response.body)).to eq({"hosts"=>[]}) + end + end +end From 4ea88b819f29a1704fbf50bc041cb07838928e52 Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Wed, 19 Apr 2023 11:42:17 +0300 Subject: [PATCH 020/665] edge hmac_api_key + test edge hmac_api_key + test edge hmac_api_key + test --- CHANGELOG.md | 2 ++ app/controllers/edge_controller.rb | 25 +++++++----------- spec/controllers/edge_controller_spec.rb | 33 +++++++++++++++++++++--- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fac26df98..49ec9d871f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [0.0.8-cloud] - 2023-04-30 ### Added - New edge-hosts endpoints for edge +- Api change. Host API key is return as hashed + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35892 ## [0.0.7-cloud] - 2023-03-27 ### Changed diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index dc2b295928..96965aaf6d 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -33,7 +33,7 @@ def all_secrets allowed_params = %i[account limit offset] options = params.permit(*allowed_params) - .slice(*allowed_params).to_h.symbolize_keys + .slice(*allowed_params).to_h.symbolize_keys begin verify_edge_host(options) scope = Resource.where(:resource_id.like(options[:account]+":variable:data/%")) @@ -112,10 +112,9 @@ def all_hosts hosts.each do |host| hostToReturn = {} hostToReturn[:id] = host[:role_id] - #salt = OpenSSL::Random.random_bytes(32) - #hostToReturn[:api_key] = Base64.encode64(hmac_api_key(host, salt)) - hostToReturn[:api_key] = host.api_key - #hostToReturn[:salt] = Base64.encode64(salt) + salt = OpenSSL::Random.random_bytes(32) + hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + hostToReturn[:salt] = Base64.strict_encode64(salt) hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} results << hostToReturn end @@ -124,8 +123,13 @@ def all_hosts end end - private + def hmac_api_key(pass, salt) + iter = 20 + key_len = 32 + OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") + end + private def validate_scope(limit, offset) if offset || limit # 'limit' must be an integer greater than 0 and less than 2000 if given @@ -138,7 +142,6 @@ def validate_scope(limit, offset) end end end - def verify_edge_host(options) msg = "" raise_excep = false @@ -170,14 +173,6 @@ def verify_edge_host(options) end end - - def hmac_api_key(host, salt) - pass = host.api_key - iter = 20 - key_len = 16 - OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") - end - def numeric? val val == val.to_i.to_s end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 1831457b00..1b604c0548 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -5,16 +5,22 @@ describe EdgeController, :type => :request do let(:account) { "rspec" } let(:host_id) {"#{account}:host:edge/edge"} - + let(:other_host_id) {"#{account}:host:data/other"} + before do Slosilo["authn:#{account}"] ||= Slosilo::Key.new @current_user = Role.find_or_create(role_id: host_id) + @other_user = Role.find_or_create(role_id: other_host_id) end - + let(:update_slosilo_keys_url) do "/edge/slosilo_keys/#{account}" end + let(:get_hosts) do + "/edge/hosts/#{account}" + end + let(:token_auth_header) do bearer_token = Slosilo["authn:#{account}"].signed_token(@current_user.login) token_auth_str = @@ -31,8 +37,8 @@ #get the Slosilo key the URL request get(update_slosilo_keys_url, env: token_auth_header) expect(response.code).to eq("200") - - #get the Slosilo key from DB + + #get the Slosilo key from DB key = Slosilo["authn:#{account}"] private_key = key.to_der.unpack("H*")[0] fingerprint = key.fingerprint @@ -58,4 +64,23 @@ expect(response.code).to eq("403") end end + + context "Host" do + it "Check HMAC" do + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + get(get_hosts, env: token_auth_header) + expect(response.code).to eq("200") + expect(response).to be_ok + expect(response.body).to include("api_key".strip) + expect(response.body).to include("salt".strip) + @result = JSON.parse(response.body) + encoded_api_key = @result['hosts'][0]['api_key'] + encoded_salt = @result['hosts'][0]['salt'] + salt = Base64.strict_decode64(encoded_salt) + test_api_key = Base64.strict_encode64(EdgeController.new.hmac_api_key(@other_user.credentials.api_key, salt)) + expect(test_api_key).to eq(encoded_api_key) + end + end end \ No newline at end of file From f17e48e2d6cefe78756ff709c5abcaec54e146a5 Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Tue, 9 May 2023 12:15:32 +0300 Subject: [PATCH 021/665] ONYX-37465: refactor hmac_api_key ONYX-37465: refactor hmac_api_key ONYX-37465: refactor hmac_api_key --- app/controllers/concerns/cryptography.rb | 15 +++++++++++++++ app/controllers/edge_controller.rb | 6 +----- spec/controllers/edge_controller_spec.rb | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 app/controllers/concerns/cryptography.rb diff --git a/app/controllers/concerns/cryptography.rb b/app/controllers/concerns/cryptography.rb new file mode 100644 index 0000000000..0a4ef6581d --- /dev/null +++ b/app/controllers/concerns/cryptography.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Cryptography is used to define HMAC or hash-based message authentication code +# this is used to hash the api key +module Cryptography + extend ActiveSupport::Concern + module_function + def hmac_api_key(pass, salt) + iter = 20 + key_len = 32 + OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") + end +end + + diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 96965aaf6d..219a816c8c 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class EdgeController < RestController + include Cryptography def slosilo_keys logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) @@ -123,11 +124,6 @@ def all_hosts end end - def hmac_api_key(pass, salt) - iter = 20 - key_len = 32 - OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") - end private def validate_scope(limit, offset) diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 1b604c0548..977a393df9 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -79,7 +79,7 @@ encoded_api_key = @result['hosts'][0]['api_key'] encoded_salt = @result['hosts'][0]['salt'] salt = Base64.strict_decode64(encoded_salt) - test_api_key = Base64.strict_encode64(EdgeController.new.hmac_api_key(@other_user.credentials.api_key, salt)) + test_api_key = Base64.strict_encode64(Cryptography.hmac_api_key(@other_user.credentials.api_key, salt)) expect(test_api_key).to eq(encoded_api_key) end end From 359987c89034bd6309c171bf5f178eda48b1e994 Mon Sep 17 00:00:00 2001 From: nofarvered Date: Tue, 9 May 2023 15:26:41 +0300 Subject: [PATCH 022/665] new varsion in CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a6c88248..12b66085e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.9-cloud] - 2023-05-09 +### Added +- Add an option to get all secrets from edge api with encode bse64, by Accept-Encoding header + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35742 + ## [0.0.8-cloud] - 2023-04-30 ### Added - New edge-hosts endpoints for edge From 2ea71da7243df7b8ded9d192179d21e2d40c07a3 Mon Sep 17 00:00:00 2001 From: nofarvered Date: Tue, 9 May 2023 15:27:38 +0300 Subject: [PATCH 023/665] testing base 64 --- cucumber/api/features/edge.feature | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 874759357a..dfade1fefa 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -304,6 +304,64 @@ Feature: Fetching secrets from edge endpoint When I successfully GET "/edge/secrets/cucumber?count=true&limit=2&offset=0" Then I receive a count of 6 + @acceptance @smoke + Scenario: Fetching special characters secret with edge host and Accept-Encoding base64 return 200 OK with json results + + Given I login as "some_user" + And I add the secret value "s1±\" to the resource "cucumber:variable:data/secret1" + And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 1 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "czHCsVw=", + "version": 2 + } + ]} + """ + + @acceptance + Scenario: Fetching all secrets with edge host without Accept-Encoding base64 and special character secret, return 500 + + Given I login as "some_user" + And I add the secret value "s1±" to the resource "cucumber:variable:data/secret1" + And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 500 + + @acceptance + Scenario: Fetching special character secret1 with edge host without Accept-Encoding base64 return 200 and json result with the bad behavior of backslash character + + Given I login as "some_user" + And I add the secret value "s1\" to the resource "cucumber:variable:data/secret1" + And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 1 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s1\\", + "version": 2 + } + ]} + """ + # Hosts ####### From 8b19dad31d47682ecb7d03a0b62adcd54fd4b3f9 Mon Sep 17 00:00:00 2001 From: nofarvered Date: Tue, 9 May 2023 15:29:00 +0300 Subject: [PATCH 024/665] add base64 encode in all_secrets function --- app/controllers/edge_controller.rb | 7 ++++++- cucumber/api/features/edge.feature | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 219a816c8c..bea76f78b4 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -62,6 +62,10 @@ def all_secrets else results = [] variables = scope.eager(:permissions).eager(:secrets).all + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + if accepts_base64 + response.set_header("Content-Encoding", "base64") + end variables.each do |variable| variableToReturn = {} variableToReturn[:id] = variable[:resource_id] @@ -69,7 +73,8 @@ def all_secrets variableToReturn[:permissions] = variable.permissions.select{|h| h[:privilege].eql?('execute')} unless variable.last_secret.nil? variableToReturn[:version] = variable.last_secret.version - variableToReturn[:value] = variable.last_secret.value + secret_value = variable.last_secret.value + variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value end results << variableToReturn end diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index dfade1fefa..433c149371 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -304,7 +304,7 @@ Feature: Fetching secrets from edge endpoint When I successfully GET "/edge/secrets/cucumber?count=true&limit=2&offset=0" Then I receive a count of 6 - @acceptance @smoke + @acceptance Scenario: Fetching special characters secret with edge host and Accept-Encoding base64 return 200 OK with json results Given I login as "some_user" @@ -329,7 +329,7 @@ Feature: Fetching secrets from edge endpoint ]} """ - @acceptance + @negative @acceptance Scenario: Fetching all secrets with edge host without Accept-Encoding base64 and special character secret, return 500 Given I login as "some_user" @@ -339,7 +339,7 @@ Feature: Fetching secrets from edge endpoint Then the HTTP response status code is 500 @acceptance - Scenario: Fetching special character secret1 with edge host without Accept-Encoding base64 return 200 and json result with the bad behavior of backslash character + Scenario: Fetching special character secret1 with edge host without Accept-Encoding base64, return 200 and json result with escaping Given I login as "some_user" And I add the secret value "s1\" to the resource "cucumber:variable:data/secret1" From 156fe137d6dfd376951699c29e4531807580424a Mon Sep 17 00:00:00 2001 From: ygeva Date: Tue, 9 May 2023 14:12:43 +0300 Subject: [PATCH 025/665] implementation of health end point with tests --- CHANGELOG.md | 5 +++++ app/controllers/health_controller.rb | 22 +++++++++++++++++++ config/initializers/rack_middleware.rb | 3 ++- config/routes.rb | 1 + cucumber/api/features/health_api.feature | 10 +++++++++ .../features/step_definitions/health_steps.rb | 9 ++++++++ spec/controllers/health_controller_spec.rb | 21 ++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 app/controllers/health_controller.rb create mode 100644 cucumber/api/features/health_api.feature create mode 100644 cucumber/api/features/step_definitions/health_steps.rb create mode 100644 spec/controllers/health_controller_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b66085e9..aa0596a01f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.10-cloud] - 2023-05-16 +### Added +- Implementation health endpoint + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37338 + ## [0.0.9-cloud] - 2023-05-09 ### Added - Add an option to get all secrets from edge api with encode bse64, by Accept-Encoding header diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 0000000000..4060ce45d0 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +class HealthController < ActionController::API + + def health + if check_db_connection + head :ok + else + head :service_unavailable + end + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + end + def check_db_connection + begin + Sequel::Model.db['SELECT 1'].single_value + return true + rescue Exception => e + return false + end + end +end \ No newline at end of file diff --git a/config/initializers/rack_middleware.rb b/config/initializers/rack_middleware.rb index e3c1e64eed..fa8b79e8c5 100644 --- a/config/initializers/rack_middleware.rb +++ b/config/initializers/rack_middleware.rb @@ -19,7 +19,8 @@ %r{^/host_factories/hosts$}, %r{^/assets/.*}, %r{^/authenticators$}, - %r{^/$} + %r{^/$}, + %r{^/health$} ]) # We want to ensure requests have an expected content type diff --git a/config/routes.rb b/config/routes.rb index cd72b2d1da..eddf1d3f0e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,6 +14,7 @@ def matches?(request) scope format: false do get '/' => 'status#index' get '/whoami' => 'status#whoami' + get '/health' => 'health#health' get '/authenticators' => 'authenticate#index' constraints id: /[^\/?]+/ do diff --git a/cucumber/api/features/health_api.feature b/cucumber/api/features/health_api.feature new file mode 100644 index 0000000000..37766583fe --- /dev/null +++ b/cucumber/api/features/health_api.feature @@ -0,0 +1,10 @@ +@api +Feature: Status page + + The health route is a simple health actuator that verifies that the API status working + + @smoke + Scenario: GET /health is reachable. + + When I GET the health route + Then the health route is reachable \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/health_steps.rb b/cucumber/api/features/step_definitions/health_steps.rb new file mode 100644 index 0000000000..506cd0956c --- /dev/null +++ b/cucumber/api/features/step_definitions/health_steps.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +When(/^I GET the health route$/) do + @response = RestClient.get(Conjur.configuration.appliance_url + "/health") +end + +Then(/^the health route is reachable$/) do + expect(@response.code).to eq(200) +end diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb new file mode 100644 index 0000000000..9f8f4498d5 --- /dev/null +++ b/spec/controllers/health_controller_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe HealthController, :type => :controller do + describe "GET health" do + it 'renders the health route sanity' do + get :health + expect(response.code).to eq("200") + end + + context "negative" do + it 'renders the health route fails' do + expect_any_instance_of(HealthController).to receive(:check_db_connection).and_return(false) + get :health + expect(response.code).to eq("503") + end + end + + end +end From 75c6d88f954130275c2fc3c4901a66d06b96aa6b Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 12:33:09 +0300 Subject: [PATCH 026/665] remove edge-hosts endpoint for edge --- app/controllers/concerns/host_verificator.rb | 33 -------- app/controllers/hosts_controller.rb | 34 -------- app/domain/logs.rb | 7 +- config/routes.rb | 1 - cucumber/api/features/hosts.feature | 85 -------------------- spec/controllers/hosts_controller_spec.rb | 74 ----------------- 6 files changed, 1 insertion(+), 233 deletions(-) delete mode 100644 app/controllers/concerns/host_verificator.rb delete mode 100644 app/controllers/hosts_controller.rb delete mode 100644 cucumber/api/features/hosts.feature delete mode 100644 spec/controllers/hosts_controller_spec.rb diff --git a/app/controllers/concerns/host_verificator.rb b/app/controllers/concerns/host_verificator.rb deleted file mode 100644 index db117c71b0..0000000000 --- a/app/controllers/concerns/host_verificator.rb +++ /dev/null @@ -1,33 +0,0 @@ - -module HostValidator - extend ActiveSupport::Concern - def validate_conjur_account(account) - if %w[conjur cucumber rspec].exclude?(account) - logger.error( - Errors::Authorization::EndpointNotVisibleToRole.new( - "Account is: #{account}. Should be one of the following: [conjur cucumber rspec]" - ) - ) - raise ApplicationController::Forbidden - end - end - - def is_role_member_of_group(account, role_id, group_name) - role = Role[account + group_name] - unless role&.ancestor_of?(role = Role[role_id]) - return false - end - return true - end - - def validate_conjur_admin_group(account) - unless is_role_member_of_group(account, current_user.id, ':group:Conjur_Cloud_Admins') - logger.error( - Errors::Authorization::EndpointNotVisibleToRole.new( - "Current user role is: #{current_user.id}. should be member of: \"group:Conjur_Cloud_Admins\"" - ) - ) - raise ApplicationController::Forbidden - end - end -end diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb deleted file mode 100644 index 0dee2f1b8e..0000000000 --- a/app/controllers/hosts_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class HostsController < RestController - include HostValidator - def edge_hosts - logger.info(LogMessages::Endpoints::EndpointRequestedByUser.new("edge-hosts", Role.username_from_roleid(current_user.role_id))) - allowed_params = %i[account] - options = params.permit(*allowed_params).to_h.symbolize_keys - begin - _verify_host(options) - rescue ApplicationController::Forbidden - raise - end - results = [] - hosts = Role.where(:role_id.like(options[:account]+":host:edge/%")) - hosts.each do |host| - id = host[:role_id] - next unless is_role_member_of_group(options[:account], id, ":group:edge/edge-hosts") - host_name = id.split('/').last - host_to_return = {} - host_to_return[:id] = id - host_to_return[:name] = host_name - results << host_to_return - end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge-hosts")) - render(json:{"hosts": results}) - end - - def _verify_host(options) - account = options[:account] - validate_conjur_account(account) - validate_conjur_admin_group(account) - end -end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index c6a26f92c2..eb7fd8217f 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -31,14 +31,9 @@ module Endpoints code: "CONJ00152" ) - EndpointRequestedByUser = ::Util::TrackableLogMessageClass.new( - msg: "{0} endpoint is called by {1} user", - code: "CONJ00153" - ) - EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( msg: "{0} endpoint is finished successfully", - code: "CONJ00154" + code: "CONJ00153" ) end diff --git a/config/routes.rb b/config/routes.rb index eddf1d3f0e..9ce17994cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,7 +79,6 @@ def matches?(request) get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' - get "/edge/edge-hosts/:account" => 'hosts#edge_hosts' put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' diff --git a/cucumber/api/features/hosts.feature b/cucumber/api/features/hosts.feature deleted file mode 100644 index 303b5cb781..0000000000 --- a/cucumber/api/features/hosts.feature +++ /dev/null @@ -1,85 +0,0 @@ -@api -Feature: Fetching edge host - Background: - Given I am the super-user - And I create a new user "alice" - And I create a new user "bob" - And I successfully PUT "/policies/cucumber/policy/root" with body: - """ - - !group Conjur_Cloud_Admins - - !grant - role: !group Conjur_Cloud_Admins - member: !user alice - - - !policy - id: edge - body: - - !group edge-hosts - - !policy - id: edge-abcd1234567890 - body: - - !host - id: edge-host-abcd1234567890 - annotations: - authn/api-key: true - - !host - id: edge-host-abcd1234567894 - annotations: - authn/api-key: true - - !host - id: edge-host-abcd1234567891 - annotations: - authn/api-key: true - - !host - id: edge-host-abcd1234567893 - annotations: - authn/api-key: true - - - !grant - role: !group edge/edge-hosts - members: - - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 - - !host edge/edge-abcd1234567890/edge-host-abcd1234567891 - # Create data tree - - !policy - id: data - owner: !group Conjur_Cloud_Admins - body: - - !group Conjur_Cloud_Admins - - !host not-edge-host - - !grant - role: !group data/Conjur_Cloud_Admins - member: !user bob - """ - - @acceptance @smoke - Scenario: Fetching edge hosts when 2 edge hosts exists - Given I login as "alice" - When I GET "/edge/edge-hosts/cucumber" - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 2 entries - Then the JSON at "hosts" should be: - """ - [ - { - "id": "cucumber:host:edge/edge-abcd1234567890/edge-host-abcd1234567890", - "name": "edge-host-abcd1234567890" - }, - { - "id": "cucumber:host:edge/edge-abcd1234567890/edge-host-abcd1234567891", - "name": "edge-host-abcd1234567891" - } - ] - """ - - @negative @acceptance - Scenario: Fetching edge host without group permission - Given I login as "bob" - When I GET "/edge/edge-hosts/cucumber" - Then the HTTP response status code is 403 - - @negative @acceptance - Scenario: Fetching edge host wrong account - Given I login as "alice" - When I GET "/edge/edge-hosts/cucumber1" - Then the HTTP response status code is 403 diff --git a/spec/controllers/hosts_controller_spec.rb b/spec/controllers/hosts_controller_spec.rb deleted file mode 100644 index e1e5705df8..0000000000 --- a/spec/controllers/hosts_controller_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'spec_helper' - -describe HostsController, :type => :request do - let(:account) { "rspec" } - let(:user_id) {"#{account}:user:admin"} - let(:host_name) {"edge-host-6d50922eedee3fa58b8f20f675fc11a3"} - let(:id) {"edge/#{host_name}/#{host_name}"} - let(:admins_group) {"Conjur_Cloud_Admins"} - let(:edge_hosts_group) {"edge/edge-hosts"} - - before do - Slosilo["authn:#{account}"] ||= Slosilo::Key.new - @current_user = Role.find_or_create(role_id: user_id) - end - - let(:token_auth_header) do - bearer_token = Slosilo["authn:#{account}"].signed_token(@current_user.login) - token_auth_str = - "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" - { 'HTTP_AUTHORIZATION' => token_auth_str } - end - - context "Edge-host api" do - include_context "create host" - let(:edge_host) { create_host(id) } - - it "User in wrong group (rspec)" do - # add user to Conjur_Cloud_Admins group - group_name = "rspec" - Role.create(role_id: "#{account}:group:#{group_name}") - RoleMembership.create(role_id: "#{account}:group:#{group_name}", member_id: user_id) - #add edge-hosts to edge/edge-hosts group - Role.create(role_id: "#{account}:group:#{edge_hosts_group}") - RoleMembership.create(role_id: "#{account}:group:#{edge_hosts_group}", member_id: edge_host.role_id) - - get("/edge/edge-hosts/#{account}", env: token_auth_header) - expect(response.code).to eq("403") - end - it "User in wrong group (cucumber)" do - # add user to Conjur_Cloud_Admins group - group_name = "cucumber" - Role.create(role_id: "#{account}:group:#{group_name}") - RoleMembership.create(role_id: "#{account}:group:#{group_name}", member_id: user_id) - #add edge-hosts to edge/edge-hosts group - Role.create(role_id: "#{account}:group:#{edge_hosts_group}") - RoleMembership.create(role_id: "#{account}:group:#{edge_hosts_group}", member_id: edge_host.role_id) - - get("/edge/edge-hosts/#{account}", env: token_auth_header) - expect(response.code).to eq("403") - end - it "Edge host in wrong group" do - # add user to Conjur_Cloud_Admins group - Role.create(role_id: "#{account}:group:#{admins_group}") - RoleMembership.create(role_id: "#{account}:group:#{admins_group}", member_id: user_id) - #add edge-hosts to edge/edge-host group - group_name = "edge/edge-host" - Role.create(role_id: "#{account}:group:#{group_name}") - RoleMembership.create(role_id: "#{account}:group:#{group_name}", member_id: edge_host.role_id) - - get("/edge/edge-hosts/#{account}", env: token_auth_header) - expect(response.code).to eq("200") - expect(JSON.parse(response.body)).to eq({"hosts"=>[]}) - end - it "No edge hosts at all" do - # add user to Conjur_Cloud_Admins group - Role.create(role_id: "#{account}:group:#{admins_group}") - RoleMembership.create(role_id: "#{account}:group:#{admins_group}", member_id: user_id) - - get("/edge/edge-hosts/#{account}", env: token_auth_header) - expect(response.code).to eq("200") - expect(JSON.parse(response.body)).to eq({"hosts"=>[]}) - end - end -end From 4ffbc9c5c02bd4f0bfbbde64696f25c49894eb52 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 12:35:28 +0300 Subject: [PATCH 027/665] add change to CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0596a01f..928af68969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.11-cloud] - 2023-05-24 +### Changed +- Remove edge-hosts for edge endpoint + ## [0.0.10-cloud] - 2023-05-16 ### Added - Implementation health endpoint From db9984474152990e0b11723d67dd4e5c4be6d8e9 Mon Sep 17 00:00:00 2001 From: amosmintzcyberark Date: Sun, 21 May 2023 21:09:44 +0300 Subject: [PATCH 028/665] ONYX-37450: user name to be compare as lowercase ONYX-37450: user name to be compare as lowercase ONYX-37450: user name to be compare as lowercase --- CHANGELOG.md | 1 + ...pdate_input_with_username_from_id_token.rb | 4 +- ..._input_with_username_from_id_token_spec.rb | 156 ++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928af68969..9200c47445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [0.0.11-cloud] - 2023-05-24 ### Changed - Remove edge-hosts for edge endpoint +- oidc user name to be compare as lowercase https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37450 ## [0.0.10-cloud] - 2023-05-16 ### Added diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index c5cc648845..903c5ad092 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -99,7 +99,7 @@ def required_variable_names end def validate_conjur_username - if conjur_username.to_s.empty? + if conjur_username.empty? raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty, id_token_username_field end @@ -117,7 +117,7 @@ def validate_conjur_username end def conjur_username - @decoded_token[id_token_username_field] + @decoded_token[id_token_username_field].to_s.downcase end def id_token_username_field diff --git a/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb b/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb index 5771599528..8223315e26 100644 --- a/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb @@ -46,6 +46,10 @@ def request_body(request) mock_authenticate_oidc_request(request_body_data: "id_token={\"id_token_username_field\": \"alice\"}", request_headers: nil) end + let(:authenticate_id_token_uppercase_request) do + mock_authenticate_oidc_request(request_body_data: "id_token={\"id_token_username_field\": \"ALICE\"}", request_headers: nil) + end + let(:authenticate_id_token_request_missing_id_token_username_field) do mock_authenticate_oidc_request(request_body_data: "id_token={}", request_headers: nil) end @@ -66,10 +70,18 @@ def request_body(request) mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"alice\"}"}) end + let(:authenticate_id_token_uppercase_request_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"ALICE\"}"}) + end + let(:authenticate_id_token_request_invalid_id_token_in_header_field) do mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "{\"id_token_username_field\":\"alice\"}"}) end + let(:authenticate_id_token_uppercase_request_invalid_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "{\"id_token_username_field\":\"ALICE\"}"}) + end + let(:authenticate_id_token_request_empty_id_token_in_header_field) do mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => ""}) end @@ -78,6 +90,10 @@ def request_body(request) mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"alice\"}"}) end + let(:authenticate_id_token_uppercase_request_missing_id_token_in_body_field) do + mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"ALICE\"}"}) + end + let(:authenticate_id_token_request_contain_only_bearer_in_header_field) do mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer"}) end @@ -156,6 +172,65 @@ def request_body(request) end end + context "with valid id upper case token" do + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + + it_behaves_like( + "it fails when variable is missing or has no value", + "provider-uri" + ) + it_behaves_like( + "it fails when variable is missing or has no value", + "id-token-user-property" + ) + + context "a non-existing account" do + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request), + client_ip: '127.0.0.1', + request: authenticate_id_token_request + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: false) + ).call( + authenticator_input: input_ + ) + end + + it "raises the error raised by validate_account_exists" do + expect { subject }.to raise_error(validate_account_exists_error) + end + end + end + context "with no id token username field in id token" do let(:audit_success) { false } subject do @@ -296,6 +371,33 @@ def request_body(request) end end + context "with valid upper case id token converted to lower case is in header" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + end + context "with invalid id token in header and in body" do let(:audit_success) { false } @@ -323,6 +425,33 @@ def request_body(request) end end + context "with invalid id upper case token in header and in body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request_invalid_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request_invalid_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "raises a MissingRequestParam error" do + expect { subject }.to raise_error(::Errors::Authentication::RequestBody::MissingRequestParam) + end + end + context "with empty id token in header and invalid in body" do let(:audit_success) { false } @@ -377,6 +506,33 @@ def request_body(request) end end + context "with valid id upper case token in header and empty body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request_missing_id_token_in_body_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request_missing_id_token_in_body_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + end + context "with bearer only in id token in header and empty body" do let(:audit_success) { false } From 27ade172a3266a31d377e51d68f2e3d8a9bdf79b Mon Sep 17 00:00:00 2001 From: ygeva Date: Mon, 22 May 2023 13:36:16 +0300 Subject: [PATCH 029/665] support secrets versions --- CHANGELOG.md | 1 + app/controllers/edge_controller.rb | 6 ++ cucumber/api/features/edge.feature | 96 ++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9200c47445..253fa6dc2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Remove edge-hosts for edge endpoint - oidc user name to be compare as lowercase https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37450 +- Support versions field in all secrets endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37056 ## [0.0.10-cloud] - 2023-05-16 ### Added diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index bea76f78b4..e104d732fb 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -75,6 +75,12 @@ def all_secrets variableToReturn[:version] = variable.last_secret.version secret_value = variable.last_secret.value variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value + variableToReturn[:versions] = [] + value = { + "version": variableToReturn[:version], + "value": variableToReturn[:value] + } + variableToReturn[:versions] << value end results << variableToReturn end diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 433c149371..e569f7bdf6 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -95,35 +95,65 @@ Feature: Fetching secrets from edge endpoint "owner": "cucumber:policy:data", "permissions": [], "value": "s1", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s1", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret2", "owner": "cucumber:policy:data", "permissions": [], "value": "s2", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s2", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret3", "owner": "cucumber:policy:data", "permissions": [], "value": "s3", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s3", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret4", "owner": "cucumber:policy:data", "permissions": [], "value": "s4", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s4", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret5", "owner": "cucumber:policy:data", "permissions": [], "value": "s5", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s5", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret6", @@ -166,7 +196,13 @@ Feature: Fetching secrets from edge endpoint "permissions": [ ], "value": "s1", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s1", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret2", @@ -174,7 +210,13 @@ Feature: Fetching secrets from edge endpoint "permissions": [ ], "value": "s2", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s2", + "version": 1 + } + ] } ]} """ @@ -193,7 +235,13 @@ Feature: Fetching secrets from edge endpoint "permissions": [ ], "value": "s3", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s3", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret4", @@ -201,7 +249,13 @@ Feature: Fetching secrets from edge endpoint "permissions": [ ], "value": "s4", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s4", + "version": 1 + } + ] } ]} """ @@ -220,7 +274,13 @@ Feature: Fetching secrets from edge endpoint "permissions": [ ], "value": "s5", - "version": 1 + "version": 1, + "versions": [ + { + "value": "s5", + "version": 1 + } + ] }, { "id": "cucumber:variable:data/secret6", @@ -324,7 +384,13 @@ Feature: Fetching secrets from edge endpoint "owner": "cucumber:policy:data", "permissions": [], "value": "czHCsVw=", - "version": 2 + "version": 2, + "versions": [ + { + "value": "czHCsVw=", + "version": 2 + } + ] } ]} """ @@ -357,7 +423,13 @@ Feature: Fetching secrets from edge endpoint "owner": "cucumber:policy:data", "permissions": [], "value": "s1\\", - "version": 2 + "version": 2, + "versions": [ + { + "value": "s1\\", + "version": 2 + } + ] } ]} """ From cc671863c394490561ee5c89654c24a6ae1fa008 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Sun, 23 Apr 2023 09:16:55 +0300 Subject: [PATCH 030/665] Improve DB queries for edge --- CHANGELOG.md | 4 ++ app/controllers/edge_controller.rb | 57 ++++++++++++------- cucumber/api/features/edge.feature | 21 ++----- .../20230216212349_add_index_to_resources.rb | 13 +++++ 4 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 db/migrate/20230216212349_add_index_to_resources.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 253fa6dc2c..d73bffef23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [0.0.12-cloud] - 2023-05-31 +### Changed +- Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 + ## [0.0.11-cloud] - 2023-05-24 ### Changed - Remove edge-hosts for edge endpoint diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index e104d732fb..a5d2c64099 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -29,6 +29,7 @@ def slosilo_keys render(json: {"slosiloKeys":[variable_to_return]}) end + # Return all secrets within offset-limit frame. Default is 0-1000 def all_secrets logger.info(LogMessages::Endpoints::EndpointRequested.new("all_secrets")) @@ -37,17 +38,14 @@ def all_secrets .slice(*allowed_params).to_h.symbolize_keys begin verify_edge_host(options) + scope = Resource.where(:resource_id.like(options[:account]+":variable:data/%")) if params[:count] == 'true' sumItems = scope.count('*'.lit) else - offset = options[:offset] - limit = options[:limit] + offset = options[:offset] || "0" + limit = options[:limit] || "1000" validate_scope(limit, offset) - scope = scope.order(:resource_id).limit( - (limit || 1000).to_i, - (offset || 0).to_i - ) end rescue ApplicationController::Forbidden raise @@ -61,27 +59,30 @@ def all_secrets render(json: results) else results = [] - variables = scope.eager(:permissions).eager(:secrets).all accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') if accepts_base64 response.set_header("Content-Encoding", "base64") end - variables.each do |variable| + + variables = build_variables_map(limit, offset, options) + + variables.each do |id, variable| variableToReturn = {} - variableToReturn[:id] = variable[:resource_id] + variableToReturn[:id] = id variableToReturn[:owner] = variable[:owner_id] - variableToReturn[:permissions] = variable.permissions.select{|h| h[:privilege].eql?('execute')} - unless variable.last_secret.nil? - variableToReturn[:version] = variable.last_secret.version - secret_value = variable.last_secret.value - variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value - variableToReturn[:versions] = [] - value = { - "version": variableToReturn[:version], - "value": variableToReturn[:value] - } - variableToReturn[:versions] << value + variableToReturn[:permissions] = [] + Sequel::Model.db.fetch("SELECT role_id from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| + variableToReturn[:permissions].append(row[:role_id]) end + secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) + variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value + variableToReturn[:version] = variable[:version] + variableToReturn[:versions] = [] + value = { + "version": variableToReturn[:version], + "value": variableToReturn[:value] + } + variableToReturn[:versions] << value results << variableToReturn end logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets")) @@ -137,6 +138,22 @@ def all_hosts private + + def build_variables_map(limit, offset, options) + variables = {} + + Sequel::Model.db.fetch("SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE '" + options[:account] + ":variable:data/%') ORDER BY resource_id LIMIT " + limit.to_s + " OFFSET " + offset.to_s + ") AS res ON (res.resource_id = secrets.resource_id)") do |row| + if variables.key?(row[:resource_id]) + if row[:version] > variables[row[:resource_id]][:version] + variables[row[:resource_id]] = row + end + else + variables[row[:resource_id]] = row + end + end + variables + end + def validate_scope(limit, offset) if offset || limit # 'limit' must be an integer greater than 0 and less than 2000 if given diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index e569f7bdf6..7bae6e30fd 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -46,6 +46,7 @@ Feature: Fetching secrets from edge endpoint And I add the secret value "s3" to the resource "cucumber:variable:data/secret3" And I add the secret value "s4" to the resource "cucumber:variable:data/secret4" And I add the secret value "s5" to the resource "cucumber:variable:data/secret5" + # secret6 has no value on purpose. Endpoint `all_secrets` should not return it And I log out # Slosilo key @@ -154,11 +155,6 @@ Feature: Fetching secrets from edge endpoint "version": 1 } ] - }, - { - "id": "cucumber:variable:data/secret6", - "owner": "cucumber:policy:data", - "permissions": [] } ]} """ @@ -281,11 +277,6 @@ Feature: Fetching secrets from edge endpoint "version": 1 } ] - }, - { - "id": "cucumber:variable:data/secret6", - "owner": "cucumber:policy:data", - "permissions": [] } ]} """ @@ -307,20 +298,20 @@ Feature: Fetching secrets from edge endpoint offset: 2 """ Then the HTTP response status code is 200 - And the JSON at "secrets" should have 4 entries + And the JSON at "secrets" should have 3 entries Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" When I GET "/edge/secrets/cucumber" with parameters: """ offset: 0 """ Then the HTTP response status code is 200 - And the JSON at "secrets" should have 6 entries + And the JSON at "secrets" should have 5 entries When I GET "/edge/secrets/cucumber" with parameters: """ offset: 2 """ Then the HTTP response status code is 200 - And the JSON at "secrets" should have 4 entries + And the JSON at "secrets" should have 3 entries When I GET "/edge/secrets/cucumber" with parameters: """ limit: 2 @@ -332,13 +323,13 @@ Feature: Fetching secrets from edge endpoint limit: 6 """ Then the HTTP response status code is 200 - And the JSON at "secrets" should have 6 entries + And the JSON at "secrets" should have 5 entries When I GET "/edge/secrets/cucumber" with parameters: """ limit: 2000 """ Then the HTTP response status code is 200 - And the JSON at "secrets" should have 6 entries + And the JSON at "secrets" should have 5 entries When I GET "/edge/secrets/cucumber" with parameters: """ limit: 0 diff --git a/db/migrate/20230216212349_add_index_to_resources.rb b/db/migrate/20230216212349_add_index_to_resources.rb new file mode 100644 index 0000000000..7b5fa4d2f9 --- /dev/null +++ b/db/migrate/20230216212349_add_index_to_resources.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + execute <<-SQL + CREATE INDEX resources_gin_trgm_idx ON resources(resource_id text_pattern_ops); + SQL + $$; + end + down do + execute "DROP INDEX IF EXISTS resources_gin_trgm_idx" + end +end From 1d1086076763953d66ef28472188b8b08b5b7d4b Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 31 May 2023 11:31:24 +0300 Subject: [PATCH 031/665] fix permissions list format in secrets endpoint --- app/controllers/edge_controller.rb | 9 +++- cucumber/api/features/edge.feature | 73 +++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index a5d2c64099..c703bec626 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -71,8 +71,13 @@ def all_secrets variableToReturn[:id] = id variableToReturn[:owner] = variable[:owner_id] variableToReturn[:permissions] = [] - Sequel::Model.db.fetch("SELECT role_id from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| - variableToReturn[:permissions].append(row[:role_id]) + Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| + permission = {} + permission[:privilege] = row[:privilege] + permission[:resource] = row[:resource_id] + permission[:role] = row[:role_id] + permission[:policy] = row[:policy_id] + variableToReturn[:permissions].append(permission) end secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 7bae6e30fd..26c76094a7 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -40,6 +40,22 @@ Feature: Fetching secrets from edge endpoint - !variable secret4 - !variable secret5 - !variable secret6 + - !permit + role: !host some_host1 + privilege: [ execute ] + resource: !variable secret1 + - !permit + role: !host some_host2 + privilege: [ execute ] + resource: !variable secret1 + - !permit + role: !host some_host3 + privilege: [ read ] + resource: !variable secret1 + - !permit + role: !host some_host4 + privilege: [ write ] + resource: !variable secret1 """ And I add the secret value "s1" to the resource "cucumber:variable:data/secret1" And I add the secret value "s2" to the resource "cucumber:variable:data/secret2" @@ -94,7 +110,19 @@ Feature: Fetching secrets from edge endpoint { "id": "cucumber:variable:data/secret1", "owner": "cucumber:policy:data", - "permissions": [], + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + } + ], "value": "s1", "version": 1, "versions": [ @@ -189,7 +217,18 @@ Feature: Fetching secrets from edge endpoint { "id": "cucumber:variable:data/secret1", "owner": "cucumber:policy:data", - "permissions": [ + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + } ], "value": "s1", "version": 1, @@ -203,8 +242,7 @@ Feature: Fetching secrets from edge endpoint { "id": "cucumber:variable:data/secret2", "owner": "cucumber:policy:data", - "permissions": [ - ], + "permissions": [], "value": "s2", "version": 1, "versions": [ @@ -373,7 +411,19 @@ Feature: Fetching secrets from edge endpoint { "id": "cucumber:variable:data/secret1", "owner": "cucumber:policy:data", - "permissions": [], + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + } + ], "value": "czHCsVw=", "version": 2, "versions": [ @@ -412,7 +462,18 @@ Feature: Fetching secrets from edge endpoint { "id": "cucumber:variable:data/secret1", "owner": "cucumber:policy:data", - "permissions": [], + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + }], "value": "s1\\", "version": 2, "versions": [ From 144bf5dde98be74a3876449441d73e3210e0ecb5 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 24 May 2023 08:58:47 +0300 Subject: [PATCH 032/665] Change Slosilo id regex in token --- CHANGELOG.md | 3 +- .../lib/conjur/rack/authenticator.rb | 18 +++-- gems/conjur-rack/lib/conjur/rack/consts.rb | 7 ++ .../spec/rack/authenticator_spec.rb | 73 ++++++++++++++++--- 4 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 gems/conjur-rack/lib/conjur/rack/consts.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4299bbbb..8b2e437e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [0.0.12-cloud] - 2023-05-31 +## [0.0.12-cloud] - 2023-06-07 ### Changed - Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 +- Change Slosilo id regex to support: authn:conjur:user/host in addition to authn:conjur ## [0.0.11-cloud] - 2023-05-24 ### Changed diff --git a/gems/conjur-rack/lib/conjur/rack/authenticator.rb b/gems/conjur-rack/lib/conjur/rack/authenticator.rb index 902240983b..55e7a259f2 100644 --- a/gems/conjur-rack/lib/conjur/rack/authenticator.rb +++ b/gems/conjur-rack/lib/conjur/rack/authenticator.rb @@ -1,4 +1,5 @@ require "conjur/rack/user" +require "conjur/rack/consts" module Conjur module Rack @@ -41,7 +42,10 @@ class SignatureError < SecurityError end class Forbidden < SecurityError end - + class ConfigurationError < SecurityError + end + class ValidationError < SecurityError + end attr_reader :app, :options # +options+: @@ -100,13 +104,15 @@ def conjur_rack end def validate_token_and_get_account token - failure = SignatureError.new("Unauthorized: Invalid token") - raise failure unless (signer = Slosilo.token_signer token) + raise(SignatureError, 'Unauthorized: Invalid token') unless (signer = Slosilo.token_signer token) if signer == 'own' - ENV['CONJUR_ACCOUNT'] or raise failure + account = ENV['CONJUR_ACCOUNT'] if ENV.has_key?('CONJUR_ACCOUNT') + return account if account && account.to_s.strip.length > 0 + raise(ConfigurationError, "Unauthorized: 'CONJUR_ACCOUNT' environment variable must be set") else - raise failure unless signer =~ /\Aauthn:(.+)\z/ - $1 + match = [] + return match[1] if (match = signer.match(Conjur::Rack::Consts::TOKEN_ID_REGEX)) + raise(ValidationError, 'Unauthorized: Invalid signer') end end diff --git a/gems/conjur-rack/lib/conjur/rack/consts.rb b/gems/conjur-rack/lib/conjur/rack/consts.rb new file mode 100644 index 0000000000..c78d7ee045 --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack/consts.rb @@ -0,0 +1,7 @@ +module Conjur + module Rack + module Consts + TOKEN_ID_REGEX = /\Aauthn:([^:]+)(?::[^:]+)*\z/ + end + end +end diff --git a/gems/conjur-rack/spec/rack/authenticator_spec.rb b/gems/conjur-rack/spec/rack/authenticator_spec.rb index 6c0a1907bb..2ef4ae4b92 100644 --- a/gems/conjur-rack/spec/rack/authenticator_spec.rb +++ b/gems/conjur-rack/spec/rack/authenticator_spec.rb @@ -81,24 +81,31 @@ context "of a token invalid for authn" do it "returns a 401 error" do allow(Slosilo).to receive(:token_signer).and_return('a-totally-different-key') - expect(call).to return_http 401, "Unauthorized: Invalid token" + expect(call).to return_http 401, "Unauthorized: Invalid signer" end end context "of 'own' token" do + before do + allow(Slosilo).to receive(:token_signer).and_return('own') + end it "returns ENV['CONJUR_ACCOUNT']" do expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return("test-account") + expect(ENV).to receive(:has_key?).with("CONJUR_ACCOUNT").and_return(true) expect(app).to receive(:call) do |*args| expect(Conjur::Rack.identity?).to be(true) expect(Conjur::Rack.user.account).to eq('test-account') :done end - allow(Slosilo).to receive(:token_signer).and_return('own') expect(call).to eq(:done) end it "requires ENV['CONJUR_ACCOUNT']" do - expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return(nil) - allow(Slosilo).to receive(:token_signer).and_return('own') - expect(call).to return_http 401, "Unauthorized: Invalid token" + expect(ENV).to receive(:has_key?).with("CONJUR_ACCOUNT").and_return(false) + expect(call).to return_http 401, "Unauthorized: 'CONJUR_ACCOUNT' environment variable must be set" + end + it "ENV['CONJUR_ACCOUNT'] can't be empty" do + expect(ENV).to receive(:has_key?).with("CONJUR_ACCOUNT").and_return(true) + expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return(' ') + expect(call).to return_http 401, "Unauthorized: 'CONJUR_ACCOUNT' environment variable must be set" end end end @@ -171,11 +178,59 @@ expect { subject.send :verify_authorization_and_get_identity }.to raise_error \ Conjur::Rack::Authenticator::AuthorizationError end + end - def mock_jwt claims - token = Slosilo::JWT.new(claims).add_signature(alg: 'none') {} - allow(subject).to receive(:parsed_token) { token } - allow(Slosilo).to receive(:token_signer).with(token).and_return 'authn:test' + describe '#validate_token_and_get_account' do + context "with 'authn:test' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'user'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end end + + context "with 'authn:test:user' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'user'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:user') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end + end + + context "with 'authn:test:host' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:host') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end + end + + context "with token signer in wrong format" do + it "raise validation error" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('wrong_account') + expect { subject.send :validate_token_and_get_account, token }.to raise_error \ + Conjur::Rack::Authenticator::ValidationError + end + end + + context "with invalid token signer" do + it "raise error" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return(nil) + expect { subject.send :validate_token_and_get_account, token }.to raise_error \ + Conjur::Rack::Authenticator::SignatureError + end + end + end + + def mock_jwt(claims, account = 'authn:test') + token = Slosilo::JWT.new(claims).add_signature(alg: 'none') {} + allow(subject).to receive(:parsed_token) { token } + allow(Slosilo).to receive(:token_signer).with(token).and_return(account) + token end end From 1fd1573d03401e3f66f1e4cdd1e43bae3a7d0a0b Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 12:59:02 +0300 Subject: [PATCH 033/665] Change token factory to sign with host/user signing key --- app/domain/token_factory.rb | 15 +++++++++----- spec/app/domain/token_factory_spec.rb | 29 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/domain/token_factory.rb b/app/domain/token_factory.rb index 14d09ce5a3..09199a3178 100644 --- a/app/domain/token_factory.rb +++ b/app/domain/token_factory.rb @@ -12,18 +12,19 @@ class TokenFactory < Dry::Struct MAXIMUM_AUTHENTICATION_TOKEN_EXPIRATION = 5.hours MINIMUM_AUTHENTICATION_TOKEN_EXPIRATION = 0 - def signing_key(account) - slosilo["authn:#{account}".to_sym] || raise(NoSigningKey, account) + def signing_key(username, account) + sloislo_key = host?(username) ? Account.token_key(account, "host") : Account.token_key(account, "user") + sloislo_key || raise(NoSigningKey, Account.token_id(account, host?(username) ? "host" : "user")) end - + def signed_token(account:, username:, host_ttl: Rails.application.config.conjur_config.host_authorization_token_ttl, user_ttl: Rails.application.config.conjur_config.user_authorization_token_ttl) - signing_key(account).issue_jwt( + signing_key(username, account).issue_jwt( sub: username, exp: Time.now + offset( - ttl: username.starts_with?('host/') ? host_ttl : user_ttl + ttl: host?(username) ? host_ttl : user_ttl ) ) end @@ -42,4 +43,8 @@ def parse_ttl(ttl:) # Attempt to coerce a string into integer ttl.to_s.to_i end + + def host?(username) + username.start_with?('host/') + end end diff --git a/spec/app/domain/token_factory_spec.rb b/spec/app/domain/token_factory_spec.rb index b060af7a44..55697ab7dc 100644 --- a/spec/app/domain/token_factory_spec.rb +++ b/spec/app/domain/token_factory_spec.rb @@ -71,4 +71,33 @@ end end end + describe '.signing_key' do + context 'Hosts key and user key are in db' do + it 'return host key' do + account = "cucumber" + key = token_factory.signing_key("host/myhost", account).to_s + expected = token_key(account, "host").to_s + expect(key).to eq(expected) + end + it 'return users key' do + account = "cucumber" + key = token_factory.signing_key("myuser", account).to_s + expected = token_key(account, "user").to_s + expect(key).to eq(expected) + end + it 'Host key is different from users key' do + account = "cucumber" + user_key = token_factory.signing_key("myuser", account).to_s + host_key = token_factory.signing_key("host/myhost", account).to_s + expect(user_key).to_not eq(host_key) + end + end + context 'User and Host Key doesnt exists in db' do + it 'Raises error' do + account = "cucumber2" + expect{token_factory.signing_key("myuser", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account 'authn:#{account}:user'") + expect{token_factory.signing_key("host/myhost", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account 'authn:#{account}:host'") + end + end + end end From c4a53429882cd1ad0f67f9b4218b03000fa8875c Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Thu, 18 May 2023 10:53:24 +0300 Subject: [PATCH 034/665] Add slosilo hosts and users keys with DB migrate script --- ...517125717_add_slosilo_for_users_and_hosts.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb diff --git a/db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb b/db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb new file mode 100644 index 0000000000..fbc984fd3b --- /dev/null +++ b/db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb @@ -0,0 +1,17 @@ +require 'rake' +require 'active_record' + +# Slosilo key id changed from authn:account to authn:conjur:host / authn:conjur:user +# Two keys (authn:conjur:host and authn:conjur:user) are added to each account upon adding new account +# This migration should run only in existing tenants that include authn:conjur account + +Sequel.migration do + up do + #update only if 'authn:conjur' account exists + if Slosilo['authn:conjur'] + Rake::Task['slosilo:generate'].execute(name:'authn:conjur:host') + Rake::Task['slosilo:generate'].execute(name:'authn:conjur:user') + run("DELETE FROM slosilo_keystore WHERE (id = 'authn:conjur');") + end + end +end From 165d0012bc3a25ec89ff8c66735e58a37daf61e4 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 12:57:53 +0300 Subject: [PATCH 035/665] Change account class and account.rake to support hosts and users slosilo key --- app/models/account.rb | 44 +++++++++++-------- cucumber/api/features/account_list.feature | 5 ++- lib/tasks/account.rake | 11 +++-- spec/conjurctl/account_spec.rb | 51 +++++++++++++++++----- spec/models/account_spec.rb | 6 ++- 5 files changed, 79 insertions(+), 38 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index 7daf1ddb11..f9df2fbd89 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require_relative '../../gems/conjur-rack/lib/conjur/rack/consts' Account = Struct.new(:id) do class << self @@ -17,15 +18,24 @@ def find_or_create_accounts_resource INVALID_ID_CHARS = /[ :]/.freeze + def token_key(account, role) + Slosilo[token_id(account, role)] + end + + def token_id(account, role) + "authn:#{account}:#{role}" + end + def create(id, owner_id = nil) - raise Exceptions::RecordExists.new("account", id) if Slosilo["authn:#{id}"] + raise Exceptions::RecordExists.new("account", id) if token_key(id, "host") || token_key(id, "user") if (invalid = INVALID_ID_CHARS.match(id)) raise ArgumentError, 'account name "%s" contains invalid characters (%s)' % [id, invalid] end Role.db.transaction do - Slosilo["authn:#{id}"] = Slosilo::Key.new + Slosilo[token_id(id, "host")] = Slosilo::Key.new + Slosilo[token_id(id, "user")] = Slosilo::Key.new role_id = "#{id}:user:admin" admin_user = Role.create(role_id: role_id) @@ -40,34 +50,34 @@ def create(id, owner_id = nil) end def list - accounts = [] - Slosilo.each do |k,v| - accounts << k - end - accounts.map do |account| - account =~ /\Aauthn:(.+)\z/ - $1 - end.delete_if do |account| - account == "!" + account_set = Set.new + Slosilo.each do |account,_| + account =~ Conjur::Rack::Consts::TOKEN_ID_REGEX + account_set.add($1) unless $1 == "!" end + account_set end end - def token_key - Slosilo["authn:#{id}"] + def token_key(role) + Account.token_key(id, role) + end + + def token_id(role) + Account.token_id(id, role) end def delete # Ensure the signing key exists - slosilo_keystore.adapter.model.with_pk!("authn:#{id}") - + slosilo_keystore.adapter.model.with_pk!(token_id("user")) + slosilo_keystore.adapter.model.with_pk!(token_id("host")) Role["#{id}:user:admin"].destroy Role["#{id}:policy:root"].try(:destroy) Resource["#{id}:user:admin"].try(:destroy) Credentials.where(Sequel.lit("account(role_id)") => id).delete Secret.where(Sequel.lit("account(resource_id)") => id).delete - slosilo_keystore.adapter.model["authn:#{id}"].destroy - + slosilo_keystore.adapter.model[token_id("user")].destroy + slosilo_keystore.adapter.model[token_id("host")].destroy true end diff --git a/cucumber/api/features/account_list.feature b/cucumber/api/features/account_list.feature index 1ce8fe76f9..8cedd7a25c 100644 --- a/cucumber/api/features/account_list.feature +++ b/cucumber/api/features/account_list.feature @@ -23,7 +23,10 @@ Feature: List accounts And I permit role "!:user:auditor" to "read" resource "!:webservice:accounts" And I login as "!:user:auditor" Then I successfully GET "/accounts" - And the JSON should include "new-account" + And the JSON response should be: + """ + ["cucumber", "new-account"] + """ And the JSON should not include "!" @acceptance diff --git a/lib/tasks/account.rake b/lib/tasks/account.rake index dfd5e05cb4..1abbb0d00f 100644 --- a/lib/tasks/account.rake +++ b/lib/tasks/account.rake @@ -1,13 +1,10 @@ # frozen_string_literal: true namespace :"account" do - def signing_key_key account - [ "authn", account ].join(":") - end desc "Test whether the token-signing key already exists" task :exists, [ "account" ] => [ "environment" ] do |t,args| - puts !!Slosilo[signing_key_key(args[:account])] + puts !!Account.token_key(args[:account], "user") && !!Account.token_key(args[:account], "host") end desc "Create an account" @@ -17,7 +14,8 @@ namespace :"account" do api_key = Account.create(args[:account]) account = Account.new(args[:account]) $stderr.puts("Created new account '#{account.id}'") - puts("Token-Signing Public Key: #{account.token_key.to_s}") + puts("Hosts' Token-Signing Public Key:\n#{account.token_key("user").to_s}") + puts("Users' Token-Signing Public Key:\n#{account.token_key("host").to_s}") puts("API key for admin: #{api_key}") rescue Exceptions::RecordExists $stderr.puts("Account '#{args[:account]}' already exists") @@ -37,7 +35,8 @@ namespace :"account" do Account.create(args[:account]) account = Account.new(args[:account]) $stderr.puts "Created new account '#{account.id}'" - puts "Token-Signing Public Key: #{account.token_key.to_s}" + puts "Hosts' Token-Signing Public Key:\n#{account.token_key("user").to_s}" + puts "Users' Token-Signing Public Key:\n#{account.token_key("host").to_s}" role_id = "#{args[:account]}:user:admin" Role[role_id].password = args[:password] diff --git a/spec/conjurctl/account_spec.rb b/spec/conjurctl/account_spec.rb index 0d79e1cc22..ac21069b44 100644 --- a/spec/conjurctl/account_spec.rb +++ b/spec/conjurctl/account_spec.rb @@ -1,26 +1,41 @@ require 'spec_helper' require 'open3' -describe "account" do - def delete_account(name) - system("conjurctl account delete #{name}") - end +def delete_account(name) + system("conjurctl account delete #{name}") +end + +def create_default_account() + system("conjurctl account create") +end +describe "account" do it "creates default account when no name provided" do stdout_str, = Open3.capture3( "conjurctl account create" ) expect(stdout_str).to include("API key for admin") - expect(Slosilo["authn:default"]).to be + expect(token_key("default", "host")).to be + expect(token_key("default", "user")).to be expect(Role["default:user:admin"]).to be + expect(Credentials["default:user:admin"]).to be delete_account("default") end + it "delete account" do + create_default_account() + delete_account("default") + expect(token_key("default", "host")).not_to be + expect(token_key("default", "user")).not_to be + expect(Role["default:user:admin"]).not_to be + expect(Credentials["default:user:admin"]).not_to be + end + context "create with name demo" do after(:each) do delete_account("demo") end - + let(:password) { "MySecretP,@SS1()!" } let(:create_account_with_password_and_name_flag) do "conjurctl account create --name demo --password-from-stdin" @@ -33,15 +48,19 @@ def delete_account(name) it "with no flags" do stdout_str, = Open3.capture3("conjurctl account create demo") expect(stdout_str).to include("API key for admin") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with the name flag" do stdout_str, = Open3.capture3("conjurctl account create --name demo") expect(stdout_str).to include("API key for admin") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with predefined password and account name flag" do @@ -49,8 +68,10 @@ def delete_account(name) create_account_with_password_and_name_flag, stdin_data: password ) expect(stdout_str).to include("Password is set") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with predefined password" do @@ -58,16 +79,20 @@ def delete_account(name) create_account_with_password_flag, stdin_data: password ) expect(stdout_str).to include("Password is set") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with both an account name argument and flag" do system( "conjurctl account create --name demo ingored_account_name" ) - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "and with invalid password" do @@ -75,8 +100,10 @@ def delete_account(name) create_account_with_password_flag, stdin_data: "invalid" ) expect(stderr_str).to include("CONJ00046E") - expect(Slosilo["authn:demo"]).not_to be + expect(token_key("demo", "host")).not_to be + expect(token_key("demo", "user")).not_to be expect(Role["demo:user:admin"]).not_to be + expect(Credentials["demo:user:admin"]).not_to be end end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 3bc639b4bc..a52c13ed68 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -14,7 +14,8 @@ def create_account it "succeeds" do create_account - expect(Slosilo["authn:#{account_name}"]).to be + expect(host_slosilo_key(account_name)).to be + expect(user_slosilo_key(account_name)).to be admin = Role["#{account_name}:user:admin"] expect(admin).to be expect(admin.credentials).to be @@ -75,7 +76,8 @@ def create_account create_account Account.new(account_name).delete - expect(Slosilo["authn:#{account_name}"]).to_not be + expect(host_slosilo_key(account_name)).to_not be + expect(user_slosilo_key(account_name)).to_not be expect(Role["#{account_name}:user:admin"]).to_not be end end From a6f9b992d9dbc831d3ef80db17107e4fe4bda561 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 13:03:18 +0300 Subject: [PATCH 036/665] Change edge slosilo endpoint to return only hosts slosilo keys --- app/controllers/edge_controller.rb | 3 +-- spec/controllers/edge_controller_spec.rb | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index c703bec626..3a6b132085 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -13,9 +13,8 @@ def slosilo_keys raise end account = options[:account] - key_id = "authn:" + account - key = Slosilo[key_id] + key = Account.token_key(account, "host") if key.nil? raise RecordNotFound, "No Slosilo key in DB" end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 977a393df9..557cf4d212 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -8,7 +8,7 @@ let(:other_host_id) {"#{account}:host:data/other"} before do - Slosilo["authn:#{account}"] ||= Slosilo::Key.new + init_slosilo_keys(account) @current_user = Role.find_or_create(role_id: host_id) @other_user = Role.find_or_create(role_id: other_host_id) end @@ -22,7 +22,7 @@ end let(:token_auth_header) do - bearer_token = Slosilo["authn:#{account}"].signed_token(@current_user.login) + bearer_token = host_slosilo_key(account).signed_token(@current_user.login) token_auth_str = "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" { 'HTTP_AUTHORIZATION' => token_auth_str } @@ -39,7 +39,7 @@ expect(response.code).to eq("200") #get the Slosilo key from DB - key = Slosilo["authn:#{account}"] + key = host_slosilo_key(account) private_key = key.to_der.unpack("H*")[0] fingerprint = key.fingerprint From 10e1d4a233e7a3b5d0c384eb717af96b53abb828 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 13:04:19 +0300 Subject: [PATCH 037/665] Modify authn-local --- app/models/authn_local.rb | 4 ++-- cucumber/api/features/step_definitions/authn_local_steps.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/authn_local.rb b/app/models/authn_local.rb index b3957bc837..703913b7c9 100644 --- a/app/models/authn_local.rb +++ b/app/models/authn_local.rb @@ -67,8 +67,8 @@ def issue_token claims claims = claims.slice("account", "sub", "exp", "cidr") (account = claims.delete("account")) || raise("'account' is required") raise "'sub' is required" unless claims['sub'] - - key = Slosilo["authn:#{account}"] + username = claims['sub'] + key = username.starts_with?('host/') ? Account.token_key(account, "host") : Account.token_key(account, "user") if key key.issue_jwt(claims).to_json else diff --git a/cucumber/api/features/step_definitions/authn_local_steps.rb b/cucumber/api/features/step_definitions/authn_local_steps.rb index 69af2b367c..54d4859c48 100644 --- a/cucumber/api/features/step_definitions/authn_local_steps.rb +++ b/cucumber/api/features/step_definitions/authn_local_steps.rb @@ -8,8 +8,8 @@ Then(/^I obtain an access token for "([^"]*)" in account "([^"]*)"$/) do |user_id, account| expect(token_payload['sub']).to eq(user_id) - - expect(token_protected['kid']).to eq(Slosilo["authn:#{account}"].fingerprint) + slosilo_key = user_id.starts_with?('host/') ? Account.token_key(account, "host") : Account.token_key(account, "user") + expect(token_protected['kid']).to eq(slosilo_key.fingerprint) end Then(/^the access token expires at (\d+)$/) do |exp| From 58307a1b9a685b12793848089d38f775185a6e1a Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 13:05:32 +0300 Subject: [PATCH 038/665] Modify cucumber tests to use solislo keys for users and hosts --- cucumber/_authenticators_common/features/support/hooks.rb | 2 +- cucumber/api/features/account_list.feature | 1 - cucumber/api/features/support/env.rb | 3 ++- cucumber/api/features/support/hooks.rb | 2 +- cucumber/api/features/support/rest_helpers.rb | 4 ++-- cucumber/authenticators/features/support/hooks.rb | 2 +- cucumber/policy/features/support/hooks.rb | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cucumber/_authenticators_common/features/support/hooks.rb b/cucumber/_authenticators_common/features/support/hooks.rb index bc93541569..562e6d3cb4 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -17,7 +17,7 @@ Credentials.truncate Slosilo.each do |k, _| - unless %w[authn:rspec authn:cucumber].member?(k) + unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) Slosilo.send(:keystore).adapter.model[k].delete end end diff --git a/cucumber/api/features/account_list.feature b/cucumber/api/features/account_list.feature index 8cedd7a25c..db3db43885 100644 --- a/cucumber/api/features/account_list.feature +++ b/cucumber/api/features/account_list.feature @@ -27,7 +27,6 @@ Feature: List accounts """ ["cucumber", "new-account"] """ - And the JSON should not include "!" @acceptance @logged-in-admin diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index edabb5a5db..9d0b141c06 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -17,6 +17,7 @@ # per Rafal's request. It could be deleted were it not for that. ENV['CONJUR_APPLIANCE_URL'] ||= Utils.start_local_server -Slosilo["authn:cucumber"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:user"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:host"] ||= Slosilo::Key.new JsonSpec.excluded_keys = %w[created_at updated_at] diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index 4e47f2f2ef..de52cd62cd 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -18,7 +18,7 @@ Credentials.truncate Slosilo.each do |k,v| - unless %w[authn:rspec authn:cucumber].member?(k) + unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) Slosilo.send(:keystore).adapter.model[k].delete end end diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index b247c30430..8a766a436a 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -289,9 +289,9 @@ def current_user_api_key def current_user_credentials username = @current_user.login # Configure Slosilo to produce valid access tokens - slosilo = Slosilo["authn:#{@current_user.account}"] ||= Slosilo::Key.new + slosilo_user = Slosilo["authn:#{@current_user.account}:user"] ||= Slosilo::Key.new # NOTE: 'iat' (issueat) is expected to be autogenerated - token = slosilo.issue_jwt(sub: username) + token = slosilo_user.issue_jwt(sub: username) user_credentials(username, token) end diff --git a/cucumber/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index 6c164a773d..fd20be26a5 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -12,7 +12,7 @@ Credentials.truncate Slosilo.each do |k, _| - unless %w[authn:rspec authn:cucumber].member?(k) + unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) Slosilo.send(:keystore).adapter.model[k].delete end end diff --git a/cucumber/policy/features/support/hooks.rb b/cucumber/policy/features/support/hooks.rb index e8742d5d59..51d0decbcc 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -25,7 +25,7 @@ Credentials.truncate Slosilo.each do |k, v| - unless %w[authn:rspec authn:cucumber].member?(k) + unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) Slosilo.send(:keystore).adapter.model[k].delete end end From 6c6f4a7249461f37e37feb853d6339595f670e14 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 17 May 2023 13:06:02 +0300 Subject: [PATCH 039/665] Modify rspec tests to use solislo keys for users and hosts --- spec/app/domain/token_factory_spec.rb | 2 +- spec/conjurctl/server_spec.rb | 9 ++++++--- .../authenticate_controller_authn_k8s_spec.rb | 4 ++-- spec/controllers/authenticate_controller_spec.rb | 4 ++-- spec/controllers/credentials_controller_spec.rb | 6 ++++-- spec/controllers/edge_controller_spec.rb | 4 ++-- spec/controllers/policies_controller_spec.rb | 4 ++-- spec/controllers/resources_controller_spec.rb | 2 +- spec/controllers/roles_controller_spec.rb | 3 +-- spec/models/account_spec.rb | 8 ++++---- spec/spec_helper.rb | 7 ++++--- spec/support/authentication.rb | 4 ++-- spec/support/slosilo_helper.rb | 14 ++++++++++++++ 13 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 spec/support/slosilo_helper.rb diff --git a/spec/app/domain/token_factory_spec.rb b/spec/app/domain/token_factory_spec.rb index 55697ab7dc..1a41f9c6d6 100644 --- a/spec/app/domain/token_factory_spec.rb +++ b/spec/app/domain/token_factory_spec.rb @@ -4,7 +4,7 @@ describe TokenFactory do - before(:all) { Slosilo["authn:cucumber"] ||= Slosilo::Key.new } + before(:all) { init_slosilo_keys("cucumber")} let(:token_factory) { TokenFactory.new } let(:ttl_limit) { 5 * 60 * 60 } # five hours diff --git a/spec/conjurctl/server_spec.rb b/spec/conjurctl/server_spec.rb index 97c730ac09..9df0294b0b 100644 --- a/spec/conjurctl/server_spec.rb +++ b/spec/conjurctl/server_spec.rb @@ -22,7 +22,8 @@ def wait_for_conjur "conjurctl server --password-from-stdin" ) expect(stderr_str).to include("account is required") - expect(Slosilo["authn:demo"]).not_to be + expect(token_key("demo", "host")).not_to be + expect(token_key("demo", "user")).not_to be expect(Role["demo:user:admin"]).not_to be end @@ -31,7 +32,8 @@ def wait_for_conjur 'conjurctl server --account demo' ) do wait_for_conjur - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be end end @@ -42,7 +44,8 @@ def wait_for_conjur conjurctl server --account demo --password-from-stdin ") do wait_for_conjur - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be end end diff --git a/spec/controllers/authenticate_controller_authn_k8s_spec.rb b/spec/controllers/authenticate_controller_authn_k8s_spec.rb index b2f73f5102..4c420f22f5 100644 --- a/spec/controllers/authenticate_controller_authn_k8s_spec.rb +++ b/spec/controllers/authenticate_controller_authn_k8s_spec.rb @@ -161,7 +161,7 @@ def capture_args(obj, *methods) # Allows API calls to be made as the admin user let(:admin_request_env) do - { 'HTTP_AUTHORIZATION' => "Token token=\"#{Base64.strict_encode64(Slosilo["authn:rspec"].signed_token("admin").to_json)}\"" } + { 'HTTP_AUTHORIZATION' => "Token token=\"#{Base64.strict_encode64(token_key("rspec", "user").signed_token("admin").to_json)}\"" } end before(:all) do @@ -169,7 +169,7 @@ def capture_args(obj, *methods) DatabaseCleaner.clean_with(:truncation) # Init Slosilo key - Slosilo["authn:rspec"] ||= Slosilo::Key.new + init_slosilo_keys("rspec") Role.create(role_id: 'rspec:user:admin') end diff --git a/spec/controllers/authenticate_controller_spec.rb b/spec/controllers/authenticate_controller_spec.rb index 24f24567e5..3d58d323f0 100644 --- a/spec/controllers/authenticate_controller_spec.rb +++ b/spec/controllers/authenticate_controller_spec.rb @@ -52,7 +52,7 @@ end context "with Token auth" do - include_context "authenticate Token" + include_context "authenticate user Token" it "is unauthorized" do post(authenticate_url, env: request_env) @@ -101,5 +101,5 @@ def invoke end end - before(:all) { Slosilo["authn:rspec"] ||= Slosilo::Key.new } + before(:all) { init_slosilo_keys("rspec") } end diff --git a/spec/controllers/credentials_controller_spec.rb b/spec/controllers/credentials_controller_spec.rb index cf9174b264..f2b544ea44 100644 --- a/spec/controllers/credentials_controller_spec.rb +++ b/spec/controllers/credentials_controller_spec.rb @@ -5,7 +5,9 @@ describe CredentialsController, :type => :request do include_context "existing account" - before(:all) { Slosilo["authn:rspec"] ||= Slosilo::Key.new } + before(:all) { + init_slosilo_keys("rspec") + } let(:login) { "u-#{random_hex}" } let(:host_login) { "h-#{random_hex}" } @@ -39,7 +41,7 @@ context "with token auth" do include_context "create user" - include_context "authenticate Token" + include_context "authenticate user Token" it_should_behave_like "authentication denied" end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 557cf4d212..4a922eaece 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -22,7 +22,7 @@ end let(:token_auth_header) do - bearer_token = host_slosilo_key(account).signed_token(@current_user.login) + bearer_token = token_key(account, "host").signed_token(@current_user.login) token_auth_str = "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" { 'HTTP_AUTHORIZATION' => token_auth_str } @@ -39,7 +39,7 @@ expect(response.code).to eq("200") #get the Slosilo key from DB - key = host_slosilo_key(account) + key = token_key(account, "host") private_key = key.to_der.unpack("H*")[0] fingerprint = key.fingerprint diff --git a/spec/controllers/policies_controller_spec.rb b/spec/controllers/policies_controller_spec.rb index d79b3cca9e..5e8bd7f47d 100644 --- a/spec/controllers/policies_controller_spec.rb +++ b/spec/controllers/policies_controller_spec.rb @@ -16,7 +16,7 @@ DatabaseCleaner.strategy = :truncation # init Slosilo key - Slosilo["authn:rspec"] ||= Slosilo::Key.new + init_slosilo_keys("rspec") end after(:all) do @@ -48,7 +48,7 @@ def variable(name) # This will require nontrivial refactoring and may be better waiting for a # larger overhaul of the test code. let(:token_auth_header) do - bearer_token = Slosilo["authn:rspec"].signed_token(current_user.login) + bearer_token = token_key("rspec", "user").signed_token(current_user.login) token_auth_str = "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" { 'HTTP_AUTHORIZATION' => token_auth_str } diff --git a/spec/controllers/resources_controller_spec.rb b/spec/controllers/resources_controller_spec.rb index 0fbb48eb9b..b93651051c 100644 --- a/spec/controllers/resources_controller_spec.rb +++ b/spec/controllers/resources_controller_spec.rb @@ -7,7 +7,7 @@ describe ResourcesController, type: :request do before do - Slosilo["authn:rspec"] ||= Slosilo::Key.new + init_slosilo_keys("rspec") end let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } diff --git a/spec/controllers/roles_controller_spec.rb b/spec/controllers/roles_controller_spec.rb index 17bbce994f..6c99051e2b 100644 --- a/spec/controllers/roles_controller_spec.rb +++ b/spec/controllers/roles_controller_spec.rb @@ -11,8 +11,7 @@ describe RolesController, type: :request do before do - Slosilo["authn:rspec"] ||= Slosilo::Key.new - + init_slosilo_keys("rspec") # Load the test policy into Conjur put( '/policies/rspec/policy/root', diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index a52c13ed68..2aa2ac6d14 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -14,8 +14,8 @@ def create_account it "succeeds" do create_account - expect(host_slosilo_key(account_name)).to be - expect(user_slosilo_key(account_name)).to be + expect(token_key(account_name, "host")).to be + expect(token_key(account_name, "user")).to be admin = Role["#{account_name}:user:admin"] expect(admin).to be expect(admin.credentials).to be @@ -76,8 +76,8 @@ def create_account create_account Account.new(account_name).delete - expect(host_slosilo_key(account_name)).to_not be - expect(user_slosilo_key(account_name)).to_not be + expect(token_key(account_name, "host")).to_not be + expect(token_key(account_name, "user")).to_not be expect(Role["#{account_name}:user:admin"]).to_not be end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3fb8bcd27a..7cd2c2950c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -102,7 +102,7 @@ def secret_logged?(secret) # :reek:UtilityFunction def access_token_for(user, account: 'rspec') # Configure Slosilo to produce valid access tokens - slosilo = Slosilo["authn:#{account}"] ||= Slosilo::Key.new + slosilo = Slosilo[token_id(account, "user")] ||= Slosilo::Key.new bearer_token = slosilo.issue_jwt(sub: user) "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" end @@ -161,8 +161,9 @@ def as_user(user, &block) Process.uid = Process.euid = prev end -def token_auth_header(role:, account: 'rspec') - bearer_token = Slosilo["authn:#{account}"].signed_token(role.login) +def token_auth_header(role:, account: 'rspec', is_user: true) + slosilo_key = is_user ? token_key(account, "user") : token_key(account, "host") + bearer_token = slosilo_key.signed_token(role.login) base64_token = Base64.strict_encode64(bearer_token.to_json) { 'HTTP_AUTHORIZATION' => "Token token=\"#{base64_token}\"" } diff --git a/spec/support/authentication.rb b/spec/support/authentication.rb index 286f2a4c6e..74aaac29c0 100644 --- a/spec/support/authentication.rb +++ b/spec/support/authentication.rb @@ -33,9 +33,9 @@ end end -shared_context "authenticate Token" do +shared_context "authenticate user Token" do let(:params) { { account: account } } - let(:bearer_token) { Slosilo["authn:rspec"].signed_token(login) } + let(:bearer_token) { token_key("rspec", "user").signed_token(login) } let(:token_auth_header) do "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" end diff --git a/spec/support/slosilo_helper.rb b/spec/support/slosilo_helper.rb new file mode 100644 index 0000000000..874cf5f551 --- /dev/null +++ b/spec/support/slosilo_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +def token_key(account, role) + Slosilo[token_id(account, role)] +end + +def token_id(account, role) + "authn:#{account}:#{role}" +end + +def init_slosilo_keys(account) + Slosilo[token_id(account, "host")] ||= Slosilo::Key.new + Slosilo[token_id(account, "user")] ||= Slosilo::Key.new +end From b3e86b6ba3b59ca99398229c87f063df4038cc3e Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 22 May 2023 14:35:51 +0300 Subject: [PATCH 040/665] Edit changelog --- CHANGELOG.md | 3 ++- app/db/preview/slosilo.rb | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 app/db/preview/slosilo.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2e437e3a..05eb08a65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [0.0.12-cloud] - 2023-06-07 +## [0.0.12-cloud] - 2023-05-31 ### Changed - Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 - Change Slosilo id regex to support: authn:conjur:user/host in addition to authn:conjur +- Split Slosilo key for hosts and users ## [0.0.11-cloud] - 2023-05-24 ### Changed diff --git a/app/db/preview/slosilo.rb b/app/db/preview/slosilo.rb new file mode 100644 index 0000000000..5f7dabfcc4 --- /dev/null +++ b/app/db/preview/slosilo.rb @@ -0,0 +1,9 @@ +module DB + module Preview + class Slosilo_exists + def is_exist? + !Slosilo["authn:conjur"].nil? + end + end + end +end \ No newline at end of file From 2ca64854ac8b8fa1b25fcec547b7034ab0cfb936 Mon Sep 17 00:00:00 2001 From: ygeva Date: Thu, 1 Jun 2023 22:58:53 +0300 Subject: [PATCH 041/665] Fix No continuation in replication when an error occurs --- CHANGELOG.md | 3 +- app/controllers/edge_controller.rb | 48 +++++++++++++----- app/domain/logs.rb | 15 +++++- cucumber/api/features/edge.feature | 81 +++++++++++++++++++++++++++--- 4 files changed, 125 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05eb08a65d..d8b810bc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [0.0.12-cloud] - 2023-05-31 +## [1.0.0-cloud] - 2023-06-07 ### Changed - Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 - Change Slosilo id regex to support: authn:conjur:user/host in addition to authn:conjur - Split Slosilo key for hosts and users +- Fix No continuation in replication when an error occurs https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35741 ## [0.0.11-cloud] - 2023-05-24 ### Changed diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 3a6b132085..9966cc8e59 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -25,7 +25,7 @@ def slosilo_keys variable_to_return[:privateKey] = private_key variable_to_return[:fingerprint] = fingerprint logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("slosilo_keys")) - render(json: {"slosiloKeys":[variable_to_return]}) + render(json: { "slosiloKeys": [variable_to_return] }) end # Return all secrets within offset-limit frame. Default is 0-1000 @@ -38,7 +38,7 @@ def all_secrets begin verify_edge_host(options) - scope = Resource.where(:resource_id.like(options[:account]+":variable:data/%")) + scope = Resource.where(:resource_id.like(options[:account] + ":variable:data/%")) if params[:count] == 'true' sumItems = scope.count('*'.lit) else @@ -58,6 +58,7 @@ def all_secrets render(json: results) else results = [] + failed = [] accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') if accepts_base64 response.set_header("Content-Encoding", "base64") @@ -87,10 +88,29 @@ def all_secrets "value": variableToReturn[:value] } variableToReturn[:versions] << value - results << variableToReturn + begin + JSON.generate(variableToReturn) + results << variableToReturn + rescue => e + failed << { "id": id } + end + + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_secrets", + limit, + offset + )) + if (failed.size > 0) + logger.info(LogMessages::Util::FailedSerializationOfResources.new( + "all_secrets", + limit, + offset, + failed.size, + failed.first + )) end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets")) - render(json: {"secrets":results}) + render(json: { "secrets": results, "failed": failed }) end end @@ -102,7 +122,7 @@ def all_hosts .slice(*allowed_params).to_h.symbolize_keys begin verify_edge_host(options) - scope = Role.where(:role_id.like(options[:account]+":host:data/%")) + scope = Role.where(:role_id.like(options[:account] + ":host:data/%")) if params[:count] == 'true' sumItems = scope.count('*'.lit) else @@ -132,15 +152,18 @@ def all_hosts salt = OpenSSL::Random.random_bytes(32) hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) hostToReturn[:salt] = Base64.strict_encode64(salt) - hostToReturn[:memberships] =host.all_roles.all.select{|h| h[:role_id] != (host[:role_id])} - results << hostToReturn + hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + results << hostToReturn end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts")) - render(json: {"hosts": results}) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_hosts", + limit, + offset + )) + render(json: { "hosts": results }) end end - private def build_variables_map(limit, offset, options) @@ -161,7 +184,7 @@ def build_variables_map(limit, offset, options) def validate_scope(limit, offset) if offset || limit # 'limit' must be an integer greater than 0 and less than 2000 if given - if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000 ) + if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000) raise ArgumentError, "'limit' contains an invalid value. 'limit' must be a positive integer and less than 2000" end # 'offset' must be an integer greater than or equal to 0 if given @@ -170,6 +193,7 @@ def validate_scope(limit, offset) end end end + def verify_edge_host(options) msg = "" raise_excep = false diff --git a/app/domain/logs.rb b/app/domain/logs.rb index eb7fd8217f..d0543fba8f 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -28,12 +28,17 @@ module Conjur module Endpoints EndpointRequested = ::Util::TrackableLogMessageClass.new( msg: "{0} endpoint is called", - code: "CONJ00152" + code: "CONJ00152I" ) EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( msg: "{0} endpoint is finished successfully", - code: "CONJ00153" + code: "CONJ00153I" + ) + + EndpointFinishedSuccessfullyWithLimitAndOffset = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully with {1}-limit {2}-offset", + code: "CONJ00155I" ) end @@ -807,6 +812,12 @@ module Util code: "CONJ00026D" ) + FailedSerializationOfResources = ::Util::TrackableLogMessageClass.new( + msg: "Failed serialization of resources in {0} endpoint with {1}-limit {2}-offset " \ + "and {3}-size of failed resources, id of failed resource {4} for example", + code: "CONJ00154I" + ) + end module Config diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 26c76094a7..ac24028d4b 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -184,7 +184,8 @@ Feature: Fetching secrets from edge endpoint } ] } - ]} + ], + "failed": []} """ @negative @acceptance @@ -252,7 +253,9 @@ Feature: Fetching secrets from edge endpoint } ] } - ]} + ], + "failed": [] + } """ When I GET "/edge/secrets/cucumber" with parameters: """ @@ -291,7 +294,8 @@ Feature: Fetching secrets from edge endpoint } ] } - ]} + ], + "failed":[]} """ When I GET "/edge/secrets/cucumber" with parameters: """ @@ -316,7 +320,8 @@ Feature: Fetching secrets from edge endpoint } ] } - ]} + ], + "failed":[]} """ @acceptance @@ -433,7 +438,8 @@ Feature: Fetching secrets from edge endpoint } ] } - ]} + ], + "failed":[]} """ @negative @acceptance @@ -443,7 +449,67 @@ Feature: Fetching secrets from edge endpoint And I add the secret value "s1±" to the resource "cucumber:variable:data/secret1" And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" When I GET "/edge/secrets/cucumber" - Then the HTTP response status code is 500 + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret2", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s2", + "version": 1, + "versions": [ + { + "value": "s2", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret3", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s3", + "version": 1, + "versions": [ + { + "value": "s3", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret4", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s4", + "version": 1, + "versions": [ + { + "value": "s4", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret5", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s5", + "version": 1, + "versions": [ + { + "value": "s5", + "version": 1 + } + ] + } + ], + "failed":[ + {"id":"cucumber:variable:data/secret1"} + ]} + """ @acceptance Scenario: Fetching special character secret1 with edge host without Accept-Encoding base64, return 200 and json result with escaping @@ -483,7 +549,8 @@ Feature: Fetching secrets from edge endpoint } ] } - ]} + ], + "failed":[]} """ # Hosts From 91a6fc1454321f4771c87bd19f7388770ace7f13 Mon Sep 17 00:00:00 2001 From: egvili Date: Mon, 5 Jun 2023 17:11:08 +0300 Subject: [PATCH 042/665] Close connections by canceling unused tasks --- CHANGELOG.md | 4 ++++ bin/conjur-cli/commands/connect_database.rb | 2 ++ bin/conjur-cli/commands/server.rb | 2 +- cucumber.yml | 2 ++ cucumber/api/features/authn_local.feature | 2 +- 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b810bc89..31eddf4aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.1-cloud] - 2023-06-08 +### Changed +- Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 + ## [1.0.0-cloud] - 2023-06-07 ### Changed - Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 diff --git a/bin/conjur-cli/commands/connect_database.rb b/bin/conjur-cli/commands/connect_database.rb index 9cda9c1dbc..af92bab26c 100644 --- a/bin/conjur-cli/commands/connect_database.rb +++ b/bin/conjur-cli/commands/connect_database.rb @@ -31,6 +31,8 @@ def call def test_select db = Sequel::Model.db = Sequel.connect(@database_url) db['select 1'].first + db.disconnect + true rescue false end diff --git a/bin/conjur-cli/commands/server.rb b/bin/conjur-cli/commands/server.rb index 3a90e2eb12..2716d0ea47 100644 --- a/bin/conjur-cli/commands/server.rb +++ b/bin/conjur-cli/commands/server.rb @@ -39,7 +39,7 @@ def call # Start the Conjur API and service # processes fork_server_process - fork_authn_local_process + #fork_authn_local_process fork_rotation_process # Block until all child processes end diff --git a/cucumber.yml b/cucumber.yml index 67b3d17cf5..25d199f682 100644 --- a/cucumber.yml +++ b/cucumber.yml @@ -5,6 +5,7 @@ policy: > api: > --format pretty + --tags "not @skip" -r cucumber/api/features/support/logs_helpers.rb -r cucumber/api/features/step_definitions/logs_steps.rb -r cucumber/_authenticators_common/features/support/conjur_token.rb @@ -149,6 +150,7 @@ authenticators_jwt: > # rotators: > --format pretty + --tags "not @skip" -t 'not @manual' -r cucumber/authenticators/features/support/hooks.rb -r cucumber/api/features/support/step_def_transforms.rb diff --git a/cucumber/api/features/authn_local.feature b/cucumber/api/features/authn_local.feature index 5f4447e9ef..0df9be492d 100644 --- a/cucumber/api/features/authn_local.feature +++ b/cucumber/api/features/authn_local.feature @@ -1,4 +1,4 @@ -@api +@api @skip Feature: Custom Authenticators can obtain access tokens for any role @smoke From 723f5ba89579b245b1b7452f91c296bbb8f42c23 Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 18 Jun 2023 16:48:46 +0300 Subject: [PATCH 043/665] Fixed base image till RDS is upgraded --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 96a88bfd99..9475f93aad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM cyberark/ubuntu-ruby-fips:latest +FROM cyberark/ubuntu-ruby-fips:2.0.7-618 ENV DEBIAN_FRONTEND=noninteractive \ PORT=80 \ From 8a634759ed3bb5763b024f890a3b8dd2c62ac717 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 21 Jun 2023 12:54:26 +0300 Subject: [PATCH 044/665] Pull slosilo library to Conjur --- .dockerignore | 3 + .gitignore | 20 ++ Gemfile | 2 +- Gemfile.lock | 8 +- gems/slosilo/.kateproject | 4 + gems/slosilo/CHANGELOG.md | 25 ++ gems/slosilo/CONTRIBUTING.md | 16 ++ gems/slosilo/Gemfile | 4 + gems/slosilo/LICENSE | 22 ++ gems/slosilo/README.md | 152 +++++++++++ gems/slosilo/Rakefile | 17 ++ gems/slosilo/SECURITY.md | 42 +++ gems/slosilo/dev/Dockerfile.dev | 7 + gems/slosilo/dev/docker-compose.yml | 8 + gems/slosilo/lib/slosilo.rb | 13 + .../lib/slosilo/adapters/abstract_adapter.rb | 23 ++ .../lib/slosilo/adapters/file_adapter.rb | 42 +++ .../lib/slosilo/adapters/memory_adapter.rb | 31 +++ .../lib/slosilo/adapters/mock_adapter.rb | 21 ++ .../lib/slosilo/adapters/sequel_adapter.rb | 96 +++++++ .../adapters/sequel_adapter/migration.rb | 52 ++++ gems/slosilo/lib/slosilo/attr_encrypted.rb | 85 ++++++ gems/slosilo/lib/slosilo/errors.rb | 15 + gems/slosilo/lib/slosilo/jwt.rb | 122 +++++++++ gems/slosilo/lib/slosilo/key.rb | 218 +++++++++++++++ gems/slosilo/lib/slosilo/keystore.rb | 89 ++++++ gems/slosilo/lib/slosilo/random.rb | 11 + gems/slosilo/lib/slosilo/symmetric.rb | 63 +++++ gems/slosilo/lib/slosilo/version.rb | 3 + gems/slosilo/lib/tasks/slosilo.rake | 32 +++ gems/slosilo/slosilo.gemspec | 34 +++ .../slosilo/spec/encrypted_attributes_spec.rb | 114 ++++++++ gems/slosilo/spec/file_adapter_spec.rb | 81 ++++++ gems/slosilo/spec/jwt_spec.rb | 102 +++++++ gems/slosilo/spec/key_spec.rb | 258 ++++++++++++++++++ gems/slosilo/spec/keystore_spec.rb | 26 ++ gems/slosilo/spec/random_spec.rb | 19 ++ gems/slosilo/spec/sequel_adapter_spec.rb | 171 ++++++++++++ gems/slosilo/spec/slosilo_spec.rb | 124 +++++++++ gems/slosilo/spec/spec_helper.rb | 84 ++++++ gems/slosilo/spec/symmetric_spec.rb | 94 +++++++ gems/slosilo/test.sh | 27 ++ 42 files changed, 2377 insertions(+), 3 deletions(-) create mode 100644 gems/slosilo/.kateproject create mode 100644 gems/slosilo/CHANGELOG.md create mode 100644 gems/slosilo/CONTRIBUTING.md create mode 100644 gems/slosilo/Gemfile create mode 100644 gems/slosilo/LICENSE create mode 100644 gems/slosilo/README.md create mode 100644 gems/slosilo/Rakefile create mode 100644 gems/slosilo/SECURITY.md create mode 100644 gems/slosilo/dev/Dockerfile.dev create mode 100644 gems/slosilo/dev/docker-compose.yml create mode 100644 gems/slosilo/lib/slosilo.rb create mode 100644 gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb create mode 100644 gems/slosilo/lib/slosilo/adapters/file_adapter.rb create mode 100644 gems/slosilo/lib/slosilo/adapters/memory_adapter.rb create mode 100644 gems/slosilo/lib/slosilo/adapters/mock_adapter.rb create mode 100644 gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb create mode 100644 gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb create mode 100644 gems/slosilo/lib/slosilo/attr_encrypted.rb create mode 100644 gems/slosilo/lib/slosilo/errors.rb create mode 100644 gems/slosilo/lib/slosilo/jwt.rb create mode 100644 gems/slosilo/lib/slosilo/key.rb create mode 100644 gems/slosilo/lib/slosilo/keystore.rb create mode 100644 gems/slosilo/lib/slosilo/random.rb create mode 100644 gems/slosilo/lib/slosilo/symmetric.rb create mode 100644 gems/slosilo/lib/slosilo/version.rb create mode 100644 gems/slosilo/lib/tasks/slosilo.rake create mode 100644 gems/slosilo/slosilo.gemspec create mode 100644 gems/slosilo/spec/encrypted_attributes_spec.rb create mode 100644 gems/slosilo/spec/file_adapter_spec.rb create mode 100644 gems/slosilo/spec/jwt_spec.rb create mode 100644 gems/slosilo/spec/key_spec.rb create mode 100644 gems/slosilo/spec/keystore_spec.rb create mode 100644 gems/slosilo/spec/random_spec.rb create mode 100644 gems/slosilo/spec/sequel_adapter_spec.rb create mode 100644 gems/slosilo/spec/slosilo_spec.rb create mode 100644 gems/slosilo/spec/spec_helper.rb create mode 100644 gems/slosilo/spec/symmetric_spec.rb create mode 100755 gems/slosilo/test.sh diff --git a/.dockerignore b/.dockerignore index 0ece2222fb..7244debd3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,11 +17,14 @@ coverage demo dev docker +gems/slosilo/Gemfile.lock +gems/slosilo/spec/reports log package run spec/reports spec/reports-audit + tmp # Ignore directories that are only relevant in gh diff --git a/.gitignore b/.gitignore index ede6e0d417..0708e2dc07 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,24 @@ conjur_git_commit # AuthnOIDC V2 w/ Identity setup dev/policies/authenticators/authn-oidc/identity-users.yml +gem/slosilo/*.gem +gem/slosilo/*.rbc +gem/slosilo/.bundle +gem/slosilo/.yardoc +gem/slosilo/InstalledFiles +gem/slosilo/_yardoc +gem/slosilo/coverage +gem/slosilo/doc/ +gem/slosilo/lib/bundler/man +gem/slosilo/pkg +gem/slosilo/rdoc +gem/slosilo/spec/reports +gem/slosilo/test/tmp +gem/slosilo/test/version_tmp +gem/slosilo/tmp +gem/slosilo/.rvmrc +gem/slosilo/.project +gem/slosilo/.kateproject.d +gem/slosilo/.idea + VERSION diff --git a/Gemfile b/Gemfile index 330c68f777..482c97d308 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,7 @@ gem 'bcrypt' gem 'gli', require: false gem 'listen' gem 'rexml', '~> 3.2' -gem 'slosilo', '~> 3.0' +gem 'slosilo', path: 'gems/slosilo' # Explicitly required as there are vulnerabilities in older versions gem "ffi", ">= 1.9.24" diff --git a/Gemfile.lock b/Gemfile.lock index 4eab22f583..9c0302cbc5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,6 +13,11 @@ PATH activesupport (>= 4.2) safe_yaml +PATH + remote: gems/slosilo + specs: + slosilo (3.0.1) + GEM remote: https://rubygems.org/ specs: @@ -440,7 +445,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - slosilo (3.0.1) spring (2.1.0) spring-commands-cucumber (1.0.1) spring (>= 0.9.1) @@ -561,7 +565,7 @@ DEPENDENCIES sequel-postgres-schemata sequel-rails simplecov - slosilo (~> 3.0) + slosilo! spring spring-commands-cucumber spring-commands-rspec diff --git a/gems/slosilo/.kateproject b/gems/slosilo/.kateproject new file mode 100644 index 0000000000..4022e4f94e --- /dev/null +++ b/gems/slosilo/.kateproject @@ -0,0 +1,4 @@ +{ + "name": "Slosilo" +, "files": [ { "git": 1 } ] +} diff --git a/gems/slosilo/CHANGELOG.md b/gems/slosilo/CHANGELOG.md new file mode 100644 index 0000000000..d287dc93f5 --- /dev/null +++ b/gems/slosilo/CHANGELOG.md @@ -0,0 +1,25 @@ +# v3.0.1 + + * The symmetric cipher class now encrypts and decrypts in a thread-safe manner. + [cyberark/slosilo#31](https://github.com/cyberark/slosilo/pull/31) + +# v3.0.0 + +* Transition to Ruby 3. Consuming projects based on Ruby 2 shall use slosilo V2.X.X. + +# v2.2.2 + +* Add rake task `slosilo:recalculate_fingerprints` which rehashes the fingerprints in the keystore. +**Note**: After migrating the slosilo keystore, run the above rake task to ensure the fingerprints are correctly hashed. + +# v2.2.1 + +* Use SHA256 algorithm instead of MD5 for public key fingerprints. + +# v2.1.1 + +* Add support for JWT-formatted tokens, with arbitrary expiration. + +# v2.0.1 + +* Fixes a bug that occurs when signing tokens containing Unicode data diff --git a/gems/slosilo/CONTRIBUTING.md b/gems/slosilo/CONTRIBUTING.md new file mode 100644 index 0000000000..7c0a67db14 --- /dev/null +++ b/gems/slosilo/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +For general contribution and community guidelines, please see the [community repo](https://github.com/cyberark/community). + +## Contributing Workflow + +1. [Fork the project](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) +2. [Clone your fork](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) +3. Make local changes to your fork by editing files +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) +4. [Push your local changes to the remote server](https://help.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) +5. [Create new Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) + +From here your pull request will be reviewed and once you've responded to all +feedback it will be merged into the project. Congratulations, you're a +contributor! diff --git a/gems/slosilo/Gemfile b/gems/slosilo/Gemfile new file mode 100644 index 0000000000..ace56bf311 --- /dev/null +++ b/gems/slosilo/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in slosilo.gemspec +gemspec diff --git a/gems/slosilo/LICENSE b/gems/slosilo/LICENSE new file mode 100644 index 0000000000..069db73dc1 --- /dev/null +++ b/gems/slosilo/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2020 CyberArk Software Ltd. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/gems/slosilo/README.md b/gems/slosilo/README.md new file mode 100644 index 0000000000..743cf6e7f5 --- /dev/null +++ b/gems/slosilo/README.md @@ -0,0 +1,152 @@ +# Slosilo + +Slosilo is providing a ruby interface to some cryptographic primitives: +- symmetric encryption, +- a mixin for easy encryption of object attributes, +- asymmetric encryption and signing, +- a keystore in a postgres sequel db -- it allows easy storage and retrieval of keys, +- a keystore in files. + +## Installation + +Add this line to your application's Gemfile: + + gem 'slosilo' + +And then execute: + + $ bundle + +## Compatibility + +Version 3.0 introduced full transition to Ruby 3. +Consumers who use slosilo in Ruby 2 projects, shall use slosilo V2.X.X. + +Version 2.0 introduced new symmetric encryption scheme using AES-256-GCM +for authenticated encryption. It allows you to provide AAD on all symmetric +encryption primitives. It's also **NOT COMPATIBLE** with CBC used in version <2. + +This means you'll have to migrate all your existing data. There's no easy way to +do this currently provided; it's recommended to create a database migration and +put relevant code fragments in it directly. (This will also have the benefit of making +the migration self-contained.) + +Since symmetric encryption is used in processing asymetrically encrypted messages, +this incompatibility extends to those too. + +## Usage + +### Symmetric encryption + +```ruby +sym = Slosilo::Symmetric.new +key = sym.random_key +# additional authenticated data +message_id = "message 001" +ciphertext = sym.encrypt "secret message", key: key, aad: message_id +``` + +```ruby +sym = Slosilo::Symmetric.new +message = sym.decrypt ciphertext, key: key, aad: message_id +``` + +### Encryption mixin + +```ruby +require 'slosilo' + +class Foo + attr_accessor :foo + attr_encrypted :foo, aad: :id + + def raw_foo + @foo + end + + def id + "unique record id" + end +end + +Slosilo::encryption_key = Slosilo::Symmetric.new.random_key + +obj = Foo.new +obj.foo = "bar" +obj.raw_foo # => "\xC4\xEF\x87\xD3b\xEA\x12\xDF\xD0\xD4hk\xEDJ\v\x1Cr\xF2#\xA3\x11\xA4*k\xB7\x8F\x8F\xC2\xBD\xBB\xFF\xE3" +obj.foo # => "bar" +``` + +You can safely use it in ie. ActiveRecord::Base or Sequel::Model subclasses. + +### Asymmetric encryption and signing + +```ruby +private_key = Slosilo::Key.new +public_key = private_key.public +``` + +#### Key dumping +```ruby +k = public_key.to_s # => "-----BEGIN PUBLIC KEY----- ... +(Slosilo::Key.new k) == public_key # => true +``` + +#### Encryption + +```ruby +encrypted = public_key.encrypt_message "eagle one sees many clouds" +# => "\xA3\x1A\xD2\xFC\xB0 ... + +public_key.decrypt_message encrypted +# => OpenSSL::PKey::RSAError: private key needed. + +private_key.decrypt_message encrypted +# => "eagle one sees many clouds" +``` + +#### Signing + +```ruby +token = private_key.signed_token "missile launch not authorized" +# => {"data"=>"missile launch not authorized", "timestamp"=>"2014-10-13 12:41:25 UTC", "signature"=>"bSImk...DzV3o", "key"=>"455f7ac42d2d483f750b4c380761821d"} + +public_key.token_valid? token # => true + +token["data"] = "missile launch authorized" +public_key.token_valid? token # => false +``` + +### Keystore + +```ruby +Slosilo::encryption_key = ENV['SLOSILO_KEY'] +Slosilo.adapter = Slosilo::Adapters::FileAdapter.new "~/.keys" + +Slosilo[:own] = Slosilo::Key.new +Slosilo[:their] = Slosilo::Key.new File.read("foo.pem") + +msg = Slosilo[:their].encrypt_message 'bar' +p Slosilo[:own].signed_token msg +``` + +### Keystore in database + +Add a migration to create the necessary table: + + require 'slosilo/adapters/sequel_adapter/migration' + +Remember to migrate your database + + $ rake db:migrate + +Then +```ruby +Slosilo.adapter = Slosilo::Adapters::SequelAdapter.new +``` + +## Contributing + +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 guide](CONTRIBUTING.md). diff --git a/gems/slosilo/Rakefile b/gems/slosilo/Rakefile new file mode 100644 index 0000000000..6130cd547f --- /dev/null +++ b/gems/slosilo/Rakefile @@ -0,0 +1,17 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" + +begin + require 'rspec/core/rake_task' + RSpec::Core::RakeTask.new(:spec) +rescue LoadError + $stderr.puts "RSpec Rake tasks not available in environment #{ENV['RACK_ENV']}" +end + +task :jenkins do + require 'ci/reporter/rake/rspec' + Rake::Task["ci:setup:rspec"].invoke + Rake::Task["spec"].invoke +end + +task :default => :spec diff --git a/gems/slosilo/SECURITY.md b/gems/slosilo/SECURITY.md new file mode 100644 index 0000000000..5315a3953e --- /dev/null +++ b/gems/slosilo/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policies and Procedures + +This document outlines security procedures and general policies for the CyberArk Conjur +suite of tools and products. + + * [Reporting a Bug](#reporting-a-bug) + * [Disclosure Policy](#disclosure-policy) + * [Comments on this Policy](#comments-on-this-policy) + +## Reporting a Bug + +The CyberArk Conjur team and community take all security bugs in the Conjur suite seriously. +Thank you for improving the security of the Conjur suite. We appreciate your efforts and +responsible disclosure and will make every effort to acknowledge your +contributions. + +Report security bugs by emailing the lead maintainers at security@conjur.org. + +The maintainers will acknowledge your email within 2 business days. Subsequently, we will +send a more detailed response within 2 business days of our acknowledgement indicating +the next steps in handling your report. After the initial reply to your report, the security +team will endeavor to keep you informed of the progress towards a fix and full +announcement, and may ask for additional information or guidance. + +Report security bugs in third-party modules to the person or team maintaining +the module. + +## Disclosure Policy + +When the security team receives a security bug report, they will assign it to a +primary handler. This person will coordinate the fix and release process, +involving the following steps: + + * Confirm the problem and determine the affected versions. + * Audit code to find any potential similar problems. + * Prepare fixes for all releases still under maintenance. These fixes will be + released as fast as possible. + +## Comments on this Policy + +If you have suggestions on how this process could be improved please submit a +pull request. diff --git a/gems/slosilo/dev/Dockerfile.dev b/gems/slosilo/dev/Dockerfile.dev new file mode 100644 index 0000000000..219b658bc6 --- /dev/null +++ b/gems/slosilo/dev/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM ruby + +COPY ./ /src/ + +WORKDIR /src + +RUN bundle diff --git a/gems/slosilo/dev/docker-compose.yml b/gems/slosilo/dev/docker-compose.yml new file mode 100644 index 0000000000..233ec2a628 --- /dev/null +++ b/gems/slosilo/dev/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3' +services: + dev: + build: + context: .. + dockerfile: dev/Dockerfile.dev + volumes: + - ../:/src diff --git a/gems/slosilo/lib/slosilo.rb b/gems/slosilo/lib/slosilo.rb new file mode 100644 index 0000000000..49594f9693 --- /dev/null +++ b/gems/slosilo/lib/slosilo.rb @@ -0,0 +1,13 @@ +require "slosilo/jwt" +require "slosilo/version" +require "slosilo/keystore" +require "slosilo/symmetric" +require "slosilo/attr_encrypted" +require "slosilo/random" +require "slosilo/errors" + +if defined? Sequel + require 'slosilo/adapters/sequel_adapter' + Slosilo::adapter = Slosilo::Adapters::SequelAdapter.new +end +Dir[File.join(File.dirname(__FILE__), 'tasks/*.rake')].each { |ext| load ext } if defined?(Rake) diff --git a/gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb b/gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb new file mode 100644 index 0000000000..5dba4cefaa --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb @@ -0,0 +1,23 @@ +require 'slosilo/attr_encrypted' + +module Slosilo + module Adapters + class AbstractAdapter + def get_key id + raise NotImplementedError + end + + def get_by_fingerprint fp + raise NotImplementedError + end + + def put_key id, key + raise NotImplementedError + end + + def each + raise NotImplementedError + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/file_adapter.rb b/gems/slosilo/lib/slosilo/adapters/file_adapter.rb new file mode 100644 index 0000000000..428995b4c1 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/file_adapter.rb @@ -0,0 +1,42 @@ +require 'slosilo/adapters/abstract_adapter' + +module Slosilo + module Adapters + class FileAdapter < AbstractAdapter + attr_reader :dir + + def initialize(dir) + @dir = dir + @keys = {} + @fingerprints = {} + Dir[File.join(@dir, "*.key")].each do |f| + key = Slosilo::EncryptedAttributes.decrypt File.read(f) + id = File.basename(f, '.key') + key = @keys[id] = Slosilo::Key.new(key) + @fingerprints[key.fingerprint] = id + end + end + + def put_key id, value + raise "id should not contain a period" if id.index('.') + fname = File.join(dir, "#{id}.key") + File.write(fname, Slosilo::EncryptedAttributes.encrypt(value.to_der)) + File.chmod(0400, fname) + @keys[id] = value + end + + def get_key id + @keys[id] + end + + def get_by_fingerprint fp + id = @fingerprints[fp] + [@keys[id], id] + end + + def each(&block) + @keys.each(&block) + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/memory_adapter.rb b/gems/slosilo/lib/slosilo/adapters/memory_adapter.rb new file mode 100644 index 0000000000..bfac8eeba3 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/memory_adapter.rb @@ -0,0 +1,31 @@ +require 'slosilo/adapters/abstract_adapter' + +module Slosilo + module Adapters + class MemoryAdapter < AbstractAdapter + def initialize + @keys = {} + @fingerprints = {} + end + + def put_key id, key + key = Slosilo::Key.new(key) if key.is_a?(String) + @keys[id] = key + @fingerprints[key.fingerprint] = id + end + + def get_key id + @keys[id] + end + + def get_by_fingerprint fp + id = @fingerprints[fp] + [@keys[id], id] + end + + def each(&block) + @keys.each(&block) + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/mock_adapter.rb b/gems/slosilo/lib/slosilo/adapters/mock_adapter.rb new file mode 100644 index 0000000000..d62805fda9 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/mock_adapter.rb @@ -0,0 +1,21 @@ +module Slosilo + module Adapters + class MockAdapter < Hash + def initialize + @fp = {} + end + + def put_key id, key + @fp[key.fingerprint] = id + self[id] = key + end + + alias :get_key :[] + + def get_by_fingerprint fp + id = @fp[fp] + [self[id], id] + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb b/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb new file mode 100644 index 0000000000..b52a75bab6 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb @@ -0,0 +1,96 @@ +require 'slosilo/adapters/abstract_adapter' + +module Slosilo + module Adapters + class SequelAdapter < AbstractAdapter + def model + @model ||= create_model + end + + def secure? + !Slosilo.encryption_key.nil? + end + + def create_model + model = Sequel::Model(:slosilo_keystore) + model.unrestrict_primary_key + model.attr_encrypted(:key, aad: :id) if secure? + model + end + + def put_key id, value + fail Error::InsecureKeyStorage unless secure? || !value.private? + + attrs = { id: id, key: value.to_der } + attrs[:fingerprint] = value.fingerprint if fingerprint_in_db? + model.create attrs + end + + def get_key id + stored = model[id] + return nil unless stored + Slosilo::Key.new stored.key + end + + def get_by_fingerprint fp + if fingerprint_in_db? + stored = model[fingerprint: fp] + return nil unless stored + [Slosilo::Key.new(stored.key), stored.id] + else + warn "Please migrate to a new database schema using rake slosilo:migrate for efficient fingerprint lookups" + find_by_fingerprint fp + end + end + + def each + model.each do |m| + yield m.id, Slosilo::Key.new(m.key) + end + end + + def recalculate_fingerprints + # Use a transaction to ensure that all fingerprints are updated together. If any update fails, + # we want to rollback all updates. + model.db.transaction do + model.each do |m| + m.update fingerprint: Slosilo::Key.new(m.key).fingerprint + end + end + end + + + def migrate! + unless fingerprint_in_db? + model.db.transaction do + model.db.alter_table :slosilo_keystore do + add_column :fingerprint, String + end + + # reload the schema + model.set_dataset model.dataset + + recalculate_fingerprints + + model.db.alter_table :slosilo_keystore do + set_column_not_null :fingerprint + add_unique_constraint :fingerprint + end + end + end + end + + private + + def fingerprint_in_db? + model.columns.include? :fingerprint + end + + def find_by_fingerprint fp + each do |id, k| + return [k, id] if k.fingerprint == fp + end + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb b/gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb new file mode 100644 index 0000000000..7cec637c24 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb @@ -0,0 +1,52 @@ +require 'sequel' + +module Slosilo + module Adapters::SequelAdapter::Migration + # The default name of the table to hold the keys + DEFAULT_KEYSTORE_TABLE = :slosilo_keystore + + # Sets up default keystore table name + def self.extended(db) + db.keystore_table ||= DEFAULT_KEYSTORE_TABLE + end + + # Keystore table name. If changing this do it immediately after loading the extension. + attr_accessor :keystore_table + + # Create the table for holding keys + def create_keystore_table + # docs say to not use create_table? in migration; + # but we really want this to be robust in case there are any previous installs + # and we can't use table_exists? because it rolls back + create_table? keystore_table do + String :id, primary_key: true + bytea :key, null: false + String :fingerprint, unique: true, null: false + end + end + + # Drop the table + def drop_keystore_table + drop_table keystore_table + end + end + + module Extension + def slosilo_keystore + extend Slosilo::Adapters::SequelAdapter::Migration + end + end + + Sequel::Database.send :include, Extension +end + +Sequel.migration do + up do + slosilo_keystore + create_keystore_table + end + down do + slosilo_keystore + drop_keystore_table + end +end diff --git a/gems/slosilo/lib/slosilo/attr_encrypted.rb b/gems/slosilo/lib/slosilo/attr_encrypted.rb new file mode 100644 index 0000000000..b860dac627 --- /dev/null +++ b/gems/slosilo/lib/slosilo/attr_encrypted.rb @@ -0,0 +1,85 @@ +require 'slosilo/symmetric' + +module Slosilo + # we don't trust the database to keep all backups safe from the prying eyes + # so we encrypt sensitive attributes before storing them + module EncryptedAttributes + module ClassMethods + + # @param options [Hash] + # @option :aad [#to_proc, #to_s] Provide additional authenticated data for + # encryption. This should be something unique to the instance having + # this attribute, such as a primary key; this will ensure that an attacker can't swap + # values around -- trying to decrypt value with a different auth data will fail. + # This means you have to be able to recover it in order to decrypt attributes. + # The following values are accepted: + # + # * Something proc-ish: will be called with self each time auth data is needed. + # * Something stringish: will be to_s-d and used for all instances as auth data. + # Note that this will only prevent swapping in data using another string. + # + # The recommended way to use this option is to pass a proc-ish that identifies the record. + # Note the proc-ish can be a simple method name; for example in case of a Sequel::Model: + # attr_encrypted :secret, aad: :pk + def attr_encrypted *a + options = a.last.is_a?(Hash) ? a.pop : {} + aad = options[:aad] + # note nil.to_s is "", which is exactly the right thing + auth_data = aad.respond_to?(:to_proc) ? aad.to_proc : proc{ |_| aad.to_s } + + # In ruby 3 .arity for #proc returns both 1 and 2, depends on internal #proc + # This method is also being called with aad which is string, in such case the arity is 1 + raise ":aad proc must take two arguments" unless (auth_data.arity.abs == 2 || auth_data.arity.abs == 1) + + # push a module onto the inheritance hierarchy + # this allows calling super in classes + include(accessors = Module.new) + accessors.module_eval do + a.each do |attr| + define_method "#{attr}=" do |value| + super(EncryptedAttributes.encrypt(value, aad: auth_data[self])) + end + define_method attr do + EncryptedAttributes.decrypt(super(), aad: auth_data[self]) + end + end + end + end + + end + + def self.included base + base.extend ClassMethods + end + + class << self + def encrypt value, opts={} + return nil unless value + cipher.encrypt value, key: key, aad: opts[:aad] + end + + def decrypt ctxt, opts={} + return nil unless ctxt + cipher.decrypt ctxt, key: key, aad: opts[:aad] + end + + def key + Slosilo::encryption_key || (raise "Please set Slosilo::encryption_key") + end + + def cipher + @cipher ||= Slosilo::Symmetric.new + end + end + end + + class << self + attr_writer :encryption_key + + def encryption_key + @encryption_key + end + end +end + +Object.send :include, Slosilo::EncryptedAttributes diff --git a/gems/slosilo/lib/slosilo/errors.rb b/gems/slosilo/lib/slosilo/errors.rb new file mode 100644 index 0000000000..abdf35521f --- /dev/null +++ b/gems/slosilo/lib/slosilo/errors.rb @@ -0,0 +1,15 @@ +module Slosilo + class Error < RuntimeError + # An error thrown when attempting to store a private key in an unecrypted + # storage. Set Slosilo.encryption_key to secure the storage or make sure + # to store just the public keys (using Key#public). + class InsecureKeyStorage < Error + def initialize msg = "can't store a private key in a plaintext storage" + super + end + end + + class TokenValidationError < Error + end + end +end diff --git a/gems/slosilo/lib/slosilo/jwt.rb b/gems/slosilo/lib/slosilo/jwt.rb new file mode 100644 index 0000000000..3baf5af790 --- /dev/null +++ b/gems/slosilo/lib/slosilo/jwt.rb @@ -0,0 +1,122 @@ +require 'json' + +module Slosilo + # A JWT-formatted Slosilo token. + # @note This is not intended to be a general-purpose JWT implementation. + class JWT + # Create a new unsigned token with the given claims. + # @param claims [#to_h] claims to embed in this token. + def initialize claims = {} + @claims = JSONHash[claims] + end + + # Parse a token in compact representation + def self.parse_compact raw + load *raw.split('.', 3).map(&Base64.method(:urlsafe_decode64)) + end + + # Parse a token in JSON representation. + # @note only single signature is currently supported. + def self.parse_json raw + raw = JSON.load raw unless raw.respond_to? :to_h + parts = raw.to_h.values_at(*%w(protected payload signature)) + fail ArgumentError, "input not a complete JWT" unless parts.all? + load *parts.map(&Base64.method(:urlsafe_decode64)) + end + + # Add a signature. + # @note currently only a single signature is handled; + # the token will be frozen after this operation. + def add_signature header, &sign + @claims = canonicalize_claims.freeze + @header = JSONHash[header].freeze + @signature = sign[string_to_sign].freeze + freeze + end + + def string_to_sign + [header, claims].map(&method(:encode)).join '.' + end + + # Returns the JSON serialization of this JWT. + def to_json *a + { + protected: encode(header), + payload: encode(claims), + signature: encode(signature) + }.to_json *a + end + + # Returns the compact serialization of this JWT. + def to_s + [header, claims, signature].map(&method(:encode)).join('.') + end + + attr_accessor :claims, :header, :signature + + private + + # Create a JWT token object from existing header, payload, and signature strings. + # @param header [#to_s] URLbase64-encoded representation of the protected header + # @param payload [#to_s] URLbase64-encoded representation of the token payload + # @param signature [#to_s] URLbase64-encoded representation of the signature + def self.load header, payload, signature + self.new(JSONHash.load payload).tap do |token| + token.header = JSONHash.load header + token.signature = signature.to_s.freeze + token.freeze + end + end + + def canonicalize_claims + claims[:iat] = Time.now unless claims.include? :iat + claims[:iat] = claims[:iat].to_time.to_i + claims[:exp] = claims[:exp].to_time.to_i if claims.include? :exp + JSONHash[claims.to_a] + end + + # Convenience method to make the above code clearer. + # Converts to string and urlbase64-encodes. + def encode s + Base64.urlsafe_encode64 s.to_s + end + + # a hash with a possibly frozen JSON stringification + class JSONHash < Hash + def to_s + @repr || to_json + end + + def freeze + @repr = to_json.freeze + super + end + + def self.load raw + self[JSON.load raw.to_s].tap do |h| + h.send :repr=, raw + end + end + + private + + def repr= raw + @repr = raw.freeze + freeze + end + end + end + + # Try to convert by detecting token representation and parsing + def self.JWT raw + if raw.is_a? JWT + raw + elsif raw.respond_to?(:to_h) || raw =~ /\A\s*\{/ + JWT.parse_json raw + else + JWT.parse_compact raw + end + rescue + raise ArgumentError, "invalid value for JWT(): #{raw.inspect}" + end +end diff --git a/gems/slosilo/lib/slosilo/key.rb b/gems/slosilo/lib/slosilo/key.rb new file mode 100644 index 0000000000..54219b0ceb --- /dev/null +++ b/gems/slosilo/lib/slosilo/key.rb @@ -0,0 +1,218 @@ +require 'openssl' +require 'json' +require 'base64' +require 'time' + +require 'slosilo/errors' + +module Slosilo + class Key + def initialize raw_key = nil + @key = if raw_key.is_a? OpenSSL::PKey::RSA + raw_key + elsif !raw_key.nil? + OpenSSL::PKey.read raw_key + else + OpenSSL::PKey::RSA.new 2048 + end + rescue OpenSSL::PKey::PKeyError => e + # old openssl versions used to report ArgumentError + # which arguably makes more sense here, so reraise as that + raise ArgumentError, e, e.backtrace + end + + attr_reader :key + + def cipher + @cipher ||= Slosilo::Symmetric.new + end + + def encrypt plaintext + key = cipher.random_key + ctxt = cipher.encrypt plaintext, key: key + key = @key.public_encrypt key + [ctxt, key] + end + + def encrypt_message plaintext + c, k = encrypt plaintext + k + c + end + + def decrypt ciphertext, skey + key = @key.private_decrypt skey + cipher.decrypt ciphertext, key: key + end + + def decrypt_message ciphertext + k, c = ciphertext.unpack("A256A*") + decrypt c, k + end + + def to_s + @key.public_key.to_pem + end + + def to_der + @to_der ||= @key.to_der + end + + def sign value + sign_string(stringify value) + end + + SIGNATURE_LEN = 256 + + def verify_signature data, signature + signature, salt = signature.unpack("a#{SIGNATURE_LEN}a*") + key.public_decrypt(signature) == hash_function.digest(salt + stringify(data)) + rescue + false + end + + # create a new timestamped and signed token carrying data + def signed_token data + token = { "data" => data, "timestamp" => Time.new.utc.to_s } + token["signature"] = Base64::urlsafe_encode64(sign token) + token["key"] = fingerprint + token + end + + JWT_ALGORITHM = 'conjur.org/slosilo/v2'.freeze + + # Issue a JWT with the given claims. + # `iat` (issued at) claim is automatically added. + # Other interesting claims you can give are: + # - `sub` - token subject, for example a user name; + # - `exp` - expiration time (absolute); + # - `cidr` (Conjur extension) - array of CIDR masks that are accepted to + # make requests that bear this token + def issue_jwt claims + token = Slosilo::JWT.new claims + token.add_signature \ + alg: JWT_ALGORITHM, + kid: fingerprint, + &method(:sign) + token.freeze + end + + DEFAULT_EXPIRATION = 8 * 60 + + def token_valid? token, expiry = DEFAULT_EXPIRATION + return jwt_valid? token if token.respond_to? :header + token = token.clone + expected_key = token.delete "key" + return false if (expected_key and (expected_key != fingerprint)) + signature = Base64::urlsafe_decode64(token.delete "signature") + (Time.parse(token["timestamp"]) + expiry > Time.now) && verify_signature(token, signature) + end + + # Validate a JWT. + # + # Convenience method calling #validate_jwt and returning false if an + # exception is raised. + # + # @param token [JWT] pre-parsed token to verify + # @return [Boolean] + def jwt_valid? token + validate_jwt token + true + rescue + false + end + + # Validate a JWT. + # + # First checks whether algorithm is 'conjur.org/slosilo/v2' and the key id + # matches this key's fingerprint. Then verifies if the token is not expired, + # as indicated by the `exp` claim; in its absence tokens are assumed to + # expire in `iat` + 8 minutes. + # + # If those checks pass, finally the signature is verified. + # + # @raises TokenValidationError if any of the checks fail. + # + # @note It's the responsibility of the caller to examine other claims + # included in the token; consideration needs to be given to handling + # unrecognized claims. + # + # @param token [JWT] pre-parsed token to verify + def validate_jwt token + def err msg + raise Error::TokenValidationError, msg, caller + end + + header = token.header + err 'unrecognized algorithm' unless header['alg'] == JWT_ALGORITHM + err 'mismatched key' if (kid = header['kid']) && kid != fingerprint + iat = Time.at token.claims['iat'] || err('unknown issuing time') + exp = Time.at token.claims['exp'] || (iat + DEFAULT_EXPIRATION) + err 'token expired' if exp <= Time.now + err 'invalid signature' unless verify_signature token.string_to_sign, token.signature + true + end + + def sign_string value + salt = shake_salt + key.private_encrypt(hash_function.digest(salt + value)) + salt + end + + def fingerprint + @fingerprint ||= OpenSSL::Digest::SHA256.hexdigest key.public_key.to_der + end + + def == other + to_der == other.to_der + end + + alias_method :eql?, :== + + def hash + to_der.hash + end + + # return a new key with just the public part of this + def public + Key.new(@key.public_key) + end + + # checks if the keypair contains a private key + def private? + @key.private? + end + + private + + # Note that this is currently somewhat shallow stringification -- + # to implement originating tokens we may need to make it deeper. + def stringify value + string = case value + when Hash + value.to_a.sort.to_json + when String + value + else + value.to_json + end + + # Make sure that the string is ascii_8bit (i.e. raw bytes), and represents + # the utf-8 encoding of the string. This accomplishes two things: it normalizes + # the representation of the string at the byte level (so we don't have an error if + # one username is submitted as ISO-whatever, and the next as UTF-16), and it prevents + # an incompatible encoding error when we concatenate it with the salt. + if string.encoding != Encoding::ASCII_8BIT + string.encode(Encoding::UTF_8).force_encoding(Encoding::ASCII_8BIT) + else + string + end + end + + def shake_salt + Slosilo::Random::salt + end + + def hash_function + @hash_function ||= OpenSSL::Digest::SHA256 + end + end +end diff --git a/gems/slosilo/lib/slosilo/keystore.rb b/gems/slosilo/lib/slosilo/keystore.rb new file mode 100644 index 0000000000..87a13e5970 --- /dev/null +++ b/gems/slosilo/lib/slosilo/keystore.rb @@ -0,0 +1,89 @@ +require 'slosilo/key' + +module Slosilo + class Keystore + def adapter + Slosilo::adapter or raise "No Slosilo adapter is configured or available" + end + + def put id, key + id = id.to_s + fail ArgumentError, "id can't be empty" if id.empty? + adapter.put_key id, key + end + + def get opts + id, fingerprint = opts.is_a?(Hash) ? [nil, opts[:fingerprint]] : [opts, nil] + if id + key = adapter.get_key(id.to_s) + elsif fingerprint + key, _ = get_by_fingerprint(fingerprint) + end + key + end + + def get_by_fingerprint fingerprint + adapter.get_by_fingerprint fingerprint + end + + def each &_ + adapter.each { |k, v| yield k, v } + end + + def any? &block + each do |_, k| + return true if yield k + end + return false + end + end + + class << self + def []= id, value + keystore.put id, value + end + + def [] id + keystore.get id + end + + def each(&block) + keystore.each(&block) + end + + def sign object + self[:own].sign object + end + + def token_valid? token + keystore.any? { |k| k.token_valid? token } + end + + # Looks up the signer by public key fingerprint and checks the validity + # of the signature. If the token is JWT, exp and/or iat claims are also + # verified; the caller is responsible for validating any other claims. + def token_signer token + begin + # see if maybe it's a JWT + token = JWT token + fingerprint = token.header['kid'] + rescue ArgumentError + fingerprint = token['key'] + end + + key, id = keystore.get_by_fingerprint fingerprint + if key && key.token_valid?(token) + return id + else + return nil + end + end + + attr_accessor :adapter + + private + def keystore + @keystore ||= Keystore.new + end + end +end diff --git a/gems/slosilo/lib/slosilo/random.rb b/gems/slosilo/lib/slosilo/random.rb new file mode 100644 index 0000000000..d78ae58578 --- /dev/null +++ b/gems/slosilo/lib/slosilo/random.rb @@ -0,0 +1,11 @@ +require 'openssl' + +module Slosilo + module Random + class << self + def salt + OpenSSL::Random::random_bytes 32 + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/symmetric.rb b/gems/slosilo/lib/slosilo/symmetric.rb new file mode 100644 index 0000000000..7c783f0bc0 --- /dev/null +++ b/gems/slosilo/lib/slosilo/symmetric.rb @@ -0,0 +1,63 @@ +module Slosilo + class Symmetric + VERSION_MAGIC = 'G' + TAG_LENGTH = 16 + + def initialize + @cipher = OpenSSL::Cipher.new 'aes-256-gcm' # NB: has to be lower case for whatever reason. + @cipher_mutex = Mutex.new + end + + # This lets us do a final sanity check in migrations from older encryption versions + def cipher_name + @cipher.name + end + + def encrypt plaintext, opts = {} + # All of these operations in OpenSSL must occur atomically, so we + # synchronize their access to make this step thread-safe. + @cipher_mutex.synchronize do + @cipher.reset + @cipher.encrypt + @cipher.key = (opts[:key] or raise("missing :key option")) + @cipher.iv = iv = random_iv + @cipher.auth_data = opts[:aad] || "" # Nothing good happens if you set this to nil, or don't set it at all + ctext = @cipher.update(plaintext) + @cipher.final + tag = @cipher.auth_tag(TAG_LENGTH) + "#{VERSION_MAGIC}#{tag}#{iv}#{ctext}" + end + end + + def decrypt ciphertext, opts = {} + version, tag, iv, ctext = unpack ciphertext + + raise "Invalid version magic: expected #{VERSION_MAGIC} but was #{version}" unless version == VERSION_MAGIC + + # All of these operations in OpenSSL must occur atomically, so we + # synchronize their access to make this step thread-safe. + @cipher_mutex.synchronize do + @cipher.reset + @cipher.decrypt + @cipher.key = opts[:key] + @cipher.iv = iv + @cipher.auth_tag = tag + @cipher.auth_data = opts[:aad] || "" + @cipher.update(ctext) + @cipher.final + end + end + + def random_iv + @cipher.random_iv + end + + def random_key + @cipher.random_key + end + + private + # return tag, iv, ctext + def unpack msg + msg.unpack "aa#{TAG_LENGTH}a#{@cipher.iv_len}a*" + end + end +end diff --git a/gems/slosilo/lib/slosilo/version.rb b/gems/slosilo/lib/slosilo/version.rb new file mode 100644 index 0000000000..27091ff1a3 --- /dev/null +++ b/gems/slosilo/lib/slosilo/version.rb @@ -0,0 +1,3 @@ +module Slosilo + VERSION = "3.0.1" +end diff --git a/gems/slosilo/lib/tasks/slosilo.rake b/gems/slosilo/lib/tasks/slosilo.rake new file mode 100644 index 0000000000..5cf153faa4 --- /dev/null +++ b/gems/slosilo/lib/tasks/slosilo.rake @@ -0,0 +1,32 @@ +namespace :slosilo do + desc "Dump a public key" + task :dump, [:name] => :environment do |t, args| + args.with_defaults(:name => :own) + puts Slosilo[args[:name]] + end + + desc "Enroll a key" + task :enroll, [:name] => :environment do |t, args| + key = Slosilo::Key.new STDIN.read + Slosilo[args[:name]] = key + puts key + end + + desc "Generate a key pair" + task :generate, [:name] => :environment do |t, args| + args.with_defaults(:name => :own) + key = Slosilo::Key.new + Slosilo[args[:name]] = key + puts key + end + + desc "Migrate to a new database schema" + task :migrate => :environment do |t| + Slosilo.adapter.migrate! + end + + desc "Recalculate fingerprints in keystore" + task :recalculate_fingerprints => :environment do |t| + Slosilo.adapter.recalculate_fingerprints + end +end diff --git a/gems/slosilo/slosilo.gemspec b/gems/slosilo/slosilo.gemspec new file mode 100644 index 0000000000..f0df057074 --- /dev/null +++ b/gems/slosilo/slosilo.gemspec @@ -0,0 +1,34 @@ +# -*- encoding: utf-8 -*- +begin + require File.expand_path('../lib/slosilo/version', __FILE__) +rescue LoadError + # so that bundle can be run without the app code + module Slosilo + VERSION = '0.0.0' + end +end + +Gem::Specification.new do |gem| + gem.name = "slosilo" + gem.version = Slosilo::VERSION + gem.authors = ["Cyberark R&D"] + gem.summary = %q{Store SSL keys in a database} + gem.description = %q{This gem provides an easy way of storing and retrieving encryption keys in the database.} + gem.homepage = "" + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + + gem.require_paths = ["lib"] + gem.required_ruby_version = '>= 3.0.0' + + gem.add_development_dependency 'rake' + gem.add_development_dependency 'rspec', '~> 3.0' + gem.add_development_dependency 'ci_reporter_rspec' + gem.add_development_dependency 'simplecov' + gem.add_development_dependency 'simplecov-cobertura' + gem.add_development_dependency 'io-grab', '~> 0.0.1' + gem.add_development_dependency 'sequel' # for sequel tests + gem.add_development_dependency 'sqlite3' # for sequel tests + gem.add_development_dependency 'activesupport' # for convenience in specs +end diff --git a/gems/slosilo/spec/encrypted_attributes_spec.rb b/gems/slosilo/spec/encrypted_attributes_spec.rb new file mode 100644 index 0000000000..d2828d9ce9 --- /dev/null +++ b/gems/slosilo/spec/encrypted_attributes_spec.rb @@ -0,0 +1,114 @@ +require 'spec_helper' +require 'slosilo/attr_encrypted' + +describe Slosilo::EncryptedAttributes do + before(:all) do + Slosilo::encryption_key = OpenSSL::Cipher.new("aes-256-gcm").random_key + end + + let(:aad) { proc{ |_| "hithere" } } + + let(:base){ + Class.new do + attr_accessor :normal_ivar,:with_aad + def stupid_ivar + side_effect! + @_explicit + end + def stupid_ivar= e + side_effect! + @_explicit = e + end + def side_effect! + + end + end + } + + let(:sub){ + Class.new(base) do + attr_encrypted :normal_ivar, :stupid_ivar + end + } + + subject{ sub.new } + + context "when setting a normal ivar" do + let(:value){ "some value" } + it "stores an encrypted value in the ivar" do + subject.normal_ivar = value + expect(subject.instance_variable_get(:"@normal_ivar")).to_not eq(value) + end + + it "recovers the value set" do + subject.normal_ivar = value + expect(subject.normal_ivar).to eq(value) + end + end + + context "when setting an attribute with an implementation" do + it "calls the base class method" do + expect(subject).to receive_messages(:side_effect! => nil) + subject.stupid_ivar = "hi" + expect(subject.stupid_ivar).to eq("hi") + end + end + + context "when given an :aad option" do + + let(:cipher){ Slosilo::EncryptedAttributes.cipher } + let(:key){ Slosilo::EncryptedAttributes.key} + context "that is a string" do + let(:aad){ "hello there" } + before{ sub.attr_encrypted :with_aad, aad: aad } + it "encrypts the value with the given string for auth data" do + expect(cipher).to receive(:encrypt).with("hello", key: key, aad: aad) + subject.with_aad = "hello" + end + + it "decrypts the encrypted value" do + subject.with_aad = "foo" + expect(subject.with_aad).to eq("foo") + end + end + + context "that is nil" do + let(:aad){ nil } + before{ sub.attr_encrypted :with_aad, aad: aad } + it "encrypts the value with an empty string for auth data" do + expect(cipher).to receive(:encrypt).with("hello",key: key, aad: "").and_call_original + subject.with_aad = "hello" + end + + it "decrypts the encrypted value" do + subject.with_aad = "hello" + expect(subject.with_aad).to eq("hello") + end + end + + context "that is a proc" do + let(:aad){ + proc{ |o| "x" } + } + + before{ sub.attr_encrypted :with_aad, aad: aad } + + it "calls the proc with the object being encrypted" do + expect(aad).to receive(:[]).with(subject).and_call_original + subject.with_aad = "hi" + end + + it "encrypts the value with the string returned for auth data" do + expect(cipher).to receive(:encrypt).with("hello", key: key, aad: aad[subject]).and_call_original + subject.with_aad = "hello" + end + it "decrypts the encrypted value" do + subject.with_aad = "hello" + expect(subject.with_aad).to eq("hello") + end + end + + end + + +end diff --git a/gems/slosilo/spec/file_adapter_spec.rb b/gems/slosilo/spec/file_adapter_spec.rb new file mode 100644 index 0000000000..6919efc6b5 --- /dev/null +++ b/gems/slosilo/spec/file_adapter_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' +require 'tmpdir' + +require 'slosilo/adapters/file_adapter' + +describe Slosilo::Adapters::FileAdapter do + include_context "with example key" + + let(:dir) { Dir.mktmpdir } + let(:adapter) { Slosilo::Adapters::FileAdapter.new dir } + subject { adapter } + + describe "#get_key" do + context "when given key does not exist" do + it "returns nil" do + expect(subject.get_key(:whatever)).not_to be + end + end + end + + describe "#put_key" do + context "unacceptable id" do + let(:id) { "foo.bar" } + it "isn't accepted" do + expect { subject.put_key id, key }.to raise_error /id should not contain a period/ + end + end + context "acceptable id" do + let(:id) { "id" } + let(:key_encrypted) { "encrypted key" } + let(:fname) { "#{dir}/#{id}.key" } + it "creates the key" do + expect(Slosilo::EncryptedAttributes).to receive(:encrypt).with(key.to_der).and_return key_encrypted + expect(File).to receive(:write).with(fname, key_encrypted) + expect(File).to receive(:chmod).with(0400, fname) + subject.put_key id, key + expect(subject.instance_variable_get("@keys")[id]).to eq(key) + end + end + end + + describe "#each" do + before { adapter.instance_variable_set("@keys", one: :onek, two: :twok) } + + it "iterates over each key" do + results = [] + adapter.each { |id,k| results << { id => k } } + expect(results).to eq([ { one: :onek}, {two: :twok } ]) + end + end + + context 'with real key store' do + let(:id) { 'some id' } + + before do + Slosilo::encryption_key = Slosilo::Symmetric.new.random_key + pre_adapter = Slosilo::Adapters::FileAdapter.new dir + pre_adapter.put_key(id, key) + end + + describe '#get_key' do + it "loads and decrypts the key" do + expect(adapter.get_key(id)).to eq(key) + end + end + + describe '#get_by_fingerprint' do + it "can look up a key by a fingerprint" do + expect(adapter.get_by_fingerprint(key_fingerprint)).to eq([key, id]) + end + end + + describe '#each' do + it "enumerates the keys" do + results = [] + adapter.each { |id,k| results << { id => k } } + expect(results).to eq([ { id => key } ]) + end + end + end +end diff --git a/gems/slosilo/spec/jwt_spec.rb b/gems/slosilo/spec/jwt_spec.rb new file mode 100644 index 0000000000..eed3bfb233 --- /dev/null +++ b/gems/slosilo/spec/jwt_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +# (Mostly) integration tests for JWT token format +describe Slosilo::Key do + include_context "with example key" + + describe '#issue_jwt' do + it 'issues an JWT token with given claims' do + allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time } + + tok = key.issue_jwt sub: 'host/example', cidr: %w(fec0::/64) + + expect(tok).to be_frozen + + expect(tok.header).to eq \ + alg: 'conjur.org/slosilo/v2', + kid: key_fingerprint + expect(tok.claims).to eq \ + iat: 1401938552, + sub: 'host/example', + cidr: ['fec0::/64'] + + expect(key.verify_signature tok.string_to_sign, tok.signature).to be_truthy + end + end +end + +describe Slosilo::JWT do + context "with a signed token" do + let(:signature) { 'very signed, such alg' } + subject(:token) { Slosilo::JWT.new test: "token" } + before do + allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time } + token.add_signature(alg: 'test-sig') { signature } + end + + it 'allows conversion to JSON representation with #to_json' do + json = JSON.load token.to_json + expect(JSON.load Base64.urlsafe_decode64 json['protected']).to eq \ + 'alg' => 'test-sig' + expect(JSON.load Base64.urlsafe_decode64 json['payload']).to eq \ + 'iat' => 1401938552, 'test' => 'token' + expect(Base64.urlsafe_decode64 json['signature']).to eq signature + end + + it 'allows conversion to compact representation with #to_s' do + h, c, s = token.to_s.split '.' + expect(JSON.load Base64.urlsafe_decode64 h).to eq \ + 'alg' => 'test-sig' + expect(JSON.load Base64.urlsafe_decode64 c).to eq \ + 'iat' => 1401938552, 'test' => 'token' + expect(Base64.urlsafe_decode64 s).to eq signature + end + end + + describe '#to_json' do + it "passes any parameters" do + token = Slosilo::JWT.new + allow(token).to receive_messages \ + header: :header, + claims: :claims, + signature: :signature + expect_any_instance_of(Hash).to receive(:to_json).with :testing + expect(token.to_json :testing) + end + end + + describe '()' do + include_context "with example key" + + it 'understands both serializations' do + [COMPACT_TOKEN, JSON_TOKEN].each do |token| + token = Slosilo::JWT token + expect(token.header).to eq \ + 'typ' => 'JWT', + 'alg' => 'conjur.org/slosilo/v2', + 'kid' => key_fingerprint + expect(token.claims).to eq \ + 'sub' => 'host/example', + 'iat' => 1401938552, + 'exp' => 1401938552 + 60*60, + 'cidr' => ['fec0::/64'] + expect(key.verify_signature token.string_to_sign, token.signature).to be_truthy + end + end + + it 'is a noop if already parsed' do + token = Slosilo::JWT COMPACT_TOKEN + expect(Slosilo::JWT token).to eq token + end + + it 'raises ArgumentError on failure to convert' do + expect { Slosilo::JWT "foo bar" }.to raise_error ArgumentError + expect { Slosilo::JWT elite: 31337 }.to raise_error ArgumentError + expect { Slosilo::JWT "foo.bar.xyzzy" }.to raise_error ArgumentError + end + end + + COMPACT_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=.eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJjaWRyIjpbImZlYzA6Oi82NCJdLCJleHAiOjE0MDE5NDIxNTIsImlhdCI6MTQwMTkzODU1Mn0=.qSxy6gx0DbiIc-Wz_vZhBsYi1SCkHhzxfMGPnnG6MTqjlzy7ntmlU2H92GKGoqCRo6AaNLA_C3hA42PeEarV5nMoTj8XJO_kwhrt2Db2OX4u83VS0_enoztWEZG5s45V0Lv71lVR530j4LD-hpqhm_f4VuISkeH84u0zX7s1zKOlniuZP-abCAHh0htTnrVz9wKG0VywkCUmWYyNNqC2h8PRf64SvCWcQ6VleHpjO-ms8OeTw4ZzRbzKMi0mL6eTmQlbT3PeBArUaS0pNJPg9zdDQaL2XDOofvQmj6Yy_8RA4eCt9HEfTYEdriVqK-_9QCspbGzFVn9GTWf51MRi5dngV9ItsDoG9ktDtqFuMttv7TcqjftsIHZXZsAZ175E".freeze + + JSON_TOKEN = "{\"protected\":\"eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=\",\"payload\":\"eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJjaWRyIjpbImZlYzA6Oi82NCJdLCJleHAiOjE0MDE5NDIxNTIsImlhdCI6MTQwMTkzODU1Mn0=\",\"signature\":\"qSxy6gx0DbiIc-Wz_vZhBsYi1SCkHhzxfMGPnnG6MTqjlzy7ntmlU2H92GKGoqCRo6AaNLA_C3hA42PeEarV5nMoTj8XJO_kwhrt2Db2OX4u83VS0_enoztWEZG5s45V0Lv71lVR530j4LD-hpqhm_f4VuISkeH84u0zX7s1zKOlniuZP-abCAHh0htTnrVz9wKG0VywkCUmWYyNNqC2h8PRf64SvCWcQ6VleHpjO-ms8OeTw4ZzRbzKMi0mL6eTmQlbT3PeBArUaS0pNJPg9zdDQaL2XDOofvQmj6Yy_8RA4eCt9HEfTYEdriVqK-_9QCspbGzFVn9GTWf51MRi5dngV9ItsDoG9ktDtqFuMttv7TcqjftsIHZXZsAZ175E\"}".freeze +end diff --git a/gems/slosilo/spec/key_spec.rb b/gems/slosilo/spec/key_spec.rb new file mode 100644 index 0000000000..5ed444fa60 --- /dev/null +++ b/gems/slosilo/spec/key_spec.rb @@ -0,0 +1,258 @@ +require 'spec_helper' + +require 'active_support' +require 'active_support/core_ext/numeric/time' + +describe Slosilo::Key do + include_context "with example key" + + subject { key } + + describe '#to_der' do + subject { super().to_der } + it { is_expected.to eq(rsa.to_der) } + end + + describe '#to_s' do + subject { super().to_s } + it { is_expected.to eq(rsa.public_key.to_pem) } + end + + describe '#fingerprint' do + subject { super().fingerprint } + it { is_expected.to eq(key_fingerprint) } + end + it { is_expected.to be_private } + + context "with identical key" do + let(:other) { Slosilo::Key.new rsa.to_der } + it "is equal" do + expect(subject).to eq(other) + end + + it "is eql?" do + expect(subject.eql?(other)).to be_truthy + end + + it "has equal hash" do + expect(subject.hash).to eq(other.hash) + end + end + + context "with a different key" do + let(:other) { Slosilo::Key.new another_rsa } + it "is not equal" do + expect(subject).not_to eq(other) + end + + it "is not eql?" do + expect(subject.eql?(other)).not_to be_truthy + end + + it "has different hash" do + expect(subject.hash).not_to eq(other.hash) + end + end + + describe '#public' do + it "returns a key with just the public half" do + pkey = subject.public + expect(pkey).to be_a(Slosilo::Key) + expect(pkey).to_not be_private + expect(pkey.key).to_not be_private + expect(pkey.to_der).to eq(rsa.public_key.to_der) + end + end + + let(:plaintext) { 'quick brown fox jumped over the lazy dog' } + describe '#encrypt' do + it "generates a symmetric encryption key and encrypts the plaintext with the public key" do + ctxt, skey = subject.encrypt plaintext + pskey = rsa.private_decrypt skey + expect(Slosilo::Symmetric.new.decrypt(ctxt, key: pskey)).to eq(plaintext) + end + end + + describe '#encrypt_message' do + it "#encrypts a message and then returns the result as a single string" do + expect(subject).to receive(:encrypt).with(plaintext).and_return ['fake ciphertext', 'fake key'] + expect(subject.encrypt_message(plaintext)).to eq('fake keyfake ciphertext') + end + end + + let(:ciphertext){ "G\xAD^\x17\x11\xBBQ9-b\x14\xF6\x92#Q0x\xF4\xAD\x1A\x92\xC3VZW\x89\x8E\x8Fg\x93\x05B\xF8\xD6O\xCFGCTp\b~\x916\xA3\x9AN\x8D\x961\x1F\xA3mSf&\xAD\xA77/]z\xA89\x01\xA7\xA9\x92\f".force_encoding('ASCII-8BIT') } + let(:skey){ "\x82\x93\xFAA\xA6wQA\xE1\xB5\xA6b\x8C.\xCF#I\x86I\x83u\x99\rTA\xEF\xC4\x91\xC5)-\xEBQ\xB1\xC0\xC6\xFF\x90L\xFE\x1E\x15\x81\x12\x16\xDD:A\xC5d\xE1B\xD2f@\xB8o\xB7+N\xB7\n\x92\xDC\x9E\xE3\x83\xB8>h\a\xC7\xCC\xCF\xD0t\x06\x8B\xA8\xBF\xEFe\xA4{\x88\f\xDD\roF\xEB.\xDA\xBF\x9D_0>\xF03c'\x1F!)*-\x19\x97\xAC\xD2\x1F(,6h\a\x93\xDB\x8E\x97\xF9\x1A\x11\x84\x11t\xD9\xB2\x85\xB0\x12\x7F\x03\x00O\x8F\xBE#\xFFb\xA5w\xF3g\xCF\xB4\xF2\xB7\xDBiA=\xA8\xFD1\xEC\xBF\xD7\x8E\xB6W>\x03\xACNBa\xBF\xFD\xC6\xB32\x8C\xE2\xF1\x87\x9C\xAE6\xD1\x12\vkl\xBB\xA0\xED\x9A\xEE6\xF2\xD9\xB4LL\xE2h/u_\xA1i=\x11x\x8DGha\x8EG\b+\x84[\x87\x8E\x01\x0E\xA5\xB0\x9F\xE9vSl\x18\xF3\xEA\xF4NH\xA8\xF1\x81\xBB\x98\x01\xE8p]\x18\x11f\xA3K\xA87c\xBB\x13X~K\xA2".force_encoding('ASCII-8BIT') } + describe '#decrypt' do + it "decrypts the symmetric key and then uses it to decrypt the ciphertext" do + expect(subject.decrypt(ciphertext, skey)).to eq(plaintext) + end + end + + describe '#decrypt_message' do + it "splits the message into key and rest, then #decrypts it" do + expect(subject).to receive(:decrypt).with(ciphertext, skey).and_return plaintext + expect(subject.decrypt_message(skey + ciphertext)).to eq(plaintext) + end + end + + describe '#initialize' do + context "when no argument given" do + subject { Slosilo::Key.new } + let (:rsa) { double "key" } + it "generates a new key pair" do + expect(OpenSSL::PKey::RSA).to receive(:new).with(2048).and_return(rsa) + expect(subject.key).to eq(rsa) + end + end + context "when given an armored key" do + subject { Slosilo::Key.new rsa.to_der } + + describe '#to_der' do + subject { super().to_der } + it { is_expected.to eq(rsa.to_der) } + end + end + context "when given a key instance" do + subject { Slosilo::Key.new rsa } + + describe '#to_der' do + subject { super().to_der } + it { is_expected.to eq(rsa.to_der) } + end + end + context "when given something else" do + subject { Slosilo::Key.new "foo" } + it "fails early" do + expect { subject }.to raise_error ArgumentError + end + end + end + + describe "#sign" do + context "when given a hash" do + it "converts to a sorted array and signs that" do + expect(key).to receive(:sign_string).with '[["a",3],["b",42]]' + key.sign b: 42, a: 3 + end + end + context "when given an array" do + it "signs a JSON representation instead" do + expect(key).to receive(:sign_string).with '[2,[42,2]]' + key.sign [2, [42, 2]] + end + end + context "when given a string" do + let(:expected_signature) { "d[\xA4\x00\x02\xC5\x17\xF5P\x1AD\x91\xF9\xC1\x00P\x0EG\x14,IN\xDE\x17\xE1\xA2a\xCC\xABR\x99'\xB0A\xF5~\x93M/\x95-B\xB1\xB6\x92!\x1E\xEA\x9C\v\xC2O\xA8\x91\x1C\xF9\x11\x92a\xBFxm-\x93\x9C\xBBoM\x92%\xA9\xD06$\xC1\xBC.`\xF8\x03J\x16\xE1\xB0c\xDD\xBF\xB0\xAA\xD7\xD4\xF4\xFC\e*\xAB\x13A%-\xD3\t\xA5R\x18\x01let6\xC8\xE9\"\x7F6O\xC7p\x82\xAB\x04J(IY\xAA]b\xA4'\xD6\x873`\xAB\x13\x95g\x9C\x17\xCAB\xF8\xB9\x85B:^\xC5XY^\x03\xEA\xB6V\x17b2\xCA\xF5\xD6\xD4\xD2\xE3u\x11\xECQ\x0Fb\x14\xE2\x04\xE1 unicode} } + + it "converts the value to raw bytes before signing it" do + expect(key).to receive(:sign_string).with("[[\"data\",\"#{encoded}\"]]").and_call_original + key.sign hash + end + end + end + + describe "#signed_token" do + let(:time) { Time.new(2012,1,1,1,1,1,0) } + let(:data) { { "foo" => :bar } } + let(:token_to_sign) { { "data" => data, "timestamp" => "2012-01-01 01:01:01 UTC" } } + let(:signature) { "signature" } + let(:salt) { 'a pinch of salt' } + let(:expected_signature) { Base64::urlsafe_encode64 "\xB0\xCE{\x9FP\xEDV\x9C\xE7b\x8B[\xFAil\x87^\x96\x17Z\x97\x1D\xC2?B\x96\x9C\x8Ep-\xDF_\x8F\xC21\xD9^\xBC\n\x16\x04\x8DJ\xF6\xAF-\xEC\xAD\x03\xF9\xEE:\xDF\xB5\x8F\xF9\xF6\x81m\xAB\x9C\xAB1\x1E\x837\x8C\xFB\xA8P\xA8<\xEA\x1Dx\xCEd\xED\x84f\xA7\xB5t`\x96\xCC\x0F\xA9t\x8B\x9Fo\xBF\x92K\xFA\xFD\xC5?\x8F\xC68t\xBC\x9F\xDE\n$\xCA\xD2\x8F\x96\x0EtX2\x8Cl\x1E\x8Aa\r\x8D\xCAi\x86\x1A\xBD\x1D\xF7\xBC\x8561j\x91YlO\xFA(\x98\x10iq\xCC\xAF\x9BV\xC6\v\xBC\x10Xm\xCD\xFE\xAD=\xAA\x95,\xB4\xF7\xE8W\xB8\x83;\x81\x88\xE6\x01\xBA\xA5F\x91\x17\f\xCE\x80\x8E\v\x83\x9D<\x0E\x83\xF6\x8D\x03\xC0\xE8A\xD7\x90i\x1D\x030VA\x906D\x10\xA0\xDE\x12\xEF\x06M\xD8\x8B\xA9W\xC8\x9DTc\x8AJ\xA4\xC0\xD3!\xFA\x14\x89\xD1p\xB4J7\xA5\x04\xC2l\xDC8<\x04Y\xD8\xA4\xFB[\x89\xB1\xEC\xDA\xB8\xD7\xEA\x03Ja pinch of salt".force_encoding("ASCII-8BIT") } + let(:expected_token) { token_to_sign.merge "signature" => expected_signature, "key" => key_fingerprint } + before do + allow(key).to receive_messages shake_salt: salt + allow(Time).to receive_messages new: time + end + subject { key.signed_token data } + it { is_expected.to eq(expected_token) } + end + + describe "#validate_jwt" do + let(:token) do + instance_double Slosilo::JWT, + header: { 'alg' => 'conjur.org/slosilo/v2' }, + claims: { 'iat' => Time.now.to_i }, + string_to_sign: double("string to sign"), + signature: double("signature") + end + + before do + allow(key).to receive(:verify_signature).with(token.string_to_sign, token.signature) { true } + end + + it "verifies the signature" do + expect { key.validate_jwt token }.not_to raise_error + end + + it "rejects unknown algorithm" do + token.header['alg'] = 'HS256' # we're not supporting standard algorithms + expect { key.validate_jwt token }.to raise_error /algorithm/ + end + + it "rejects bad signature" do + allow(key).to receive(:verify_signature).with(token.string_to_sign, token.signature) { false } + expect { key.validate_jwt token }.to raise_error /signature/ + end + + it "rejects expired token" do + token.claims['exp'] = 1.hour.ago.to_i + expect { key.validate_jwt token }.to raise_error /expired/ + end + + it "accepts unexpired token with implicit expiration" do + token.claims['iat'] = 5.minutes.ago + expect { key.validate_jwt token }.to_not raise_error + end + + it "rejects token expired with implicit expiration" do + token.claims['iat'] = 10.minutes.ago.to_i + expect { key.validate_jwt token }.to raise_error /expired/ + end + end + + describe "#token_valid?" do + let(:data) { { "foo" => :bar } } + let(:signature) { Base64::urlsafe_encode64 "\xB0\xCE{\x9FP\xEDV\x9C\xE7b\x8B[\xFAil\x87^\x96\x17Z\x97\x1D\xC2?B\x96\x9C\x8Ep-\xDF_\x8F\xC21\xD9^\xBC\n\x16\x04\x8DJ\xF6\xAF-\xEC\xAD\x03\xF9\xEE:\xDF\xB5\x8F\xF9\xF6\x81m\xAB\x9C\xAB1\x1E\x837\x8C\xFB\xA8P\xA8<\xEA\x1Dx\xCEd\xED\x84f\xA7\xB5t`\x96\xCC\x0F\xA9t\x8B\x9Fo\xBF\x92K\xFA\xFD\xC5?\x8F\xC68t\xBC\x9F\xDE\n$\xCA\xD2\x8F\x96\x0EtX2\x8Cl\x1E\x8Aa\r\x8D\xCAi\x86\x1A\xBD\x1D\xF7\xBC\x8561j\x91YlO\xFA(\x98\x10iq\xCC\xAF\x9BV\xC6\v\xBC\x10Xm\xCD\xFE\xAD=\xAA\x95,\xB4\xF7\xE8W\xB8\x83;\x81\x88\xE6\x01\xBA\xA5F\x91\x17\f\xCE\x80\x8E\v\x83\x9D<\x0E\x83\xF6\x8D\x03\xC0\xE8A\xD7\x90i\x1D\x030VA\x906D\x10\xA0\xDE\x12\xEF\x06M\xD8\x8B\xA9W\xC8\x9DTc\x8AJ\xA4\xC0\xD3!\xFA\x14\x89\xD1p\xB4J7\xA5\x04\xC2l\xDC8<\x04Y\xD8\xA4\xFB[\x89\xB1\xEC\xDA\xB8\xD7\xEA\x03Ja pinch of salt".force_encoding("ASCII-8BIT") } + let(:token) { { "data" => data, "timestamp" => "2012-01-01 01:01:01 UTC", "signature" => signature } } + before { allow(Time).to receive_messages now: Time.new(2012,1,1,1,2,1,0) } + subject { key.token_valid? token } + it { is_expected.to be_truthy } + + it "doesn't check signature on the advisory key field" do + expect(key.token_valid?(token.merge "key" => key_fingerprint)).to be_truthy + end + + it "rejects the token if the key field is present and doesn't match" do + expect(key.token_valid?(token.merge "key" => "this is not the key you are looking for")).not_to be_truthy + end + + context "when token is 1 hour old" do + before { allow(Time).to receive_messages now: Time.new(2012,1,1,2,1,1,0) } + it { is_expected.to be_falsey } + context "when timestamp in the token is changed accordingly" do + let(:token) { { "data" => data, "timestamp" => "2012-01-01 02:00:01 UTC", "signature" => signature } } + it { is_expected.to be_falsey } + end + end + context "when the data is changed" do + let(:data) { { "foo" => :baz } } + it { is_expected.to be_falsey } + end + context "when RSA decrypt raises an error" do + before { expect_any_instance_of(OpenSSL::PKey::RSA).to receive(:public_decrypt).and_raise(OpenSSL::PKey::RSAError) } + it { is_expected.to be_falsey } + end + end +end diff --git a/gems/slosilo/spec/keystore_spec.rb b/gems/slosilo/spec/keystore_spec.rb new file mode 100644 index 0000000000..d11db76b8f --- /dev/null +++ b/gems/slosilo/spec/keystore_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Slosilo::Keystore do + include_context "with example key" + include_context "with mock adapter" + + describe '#put' do + it "handles Slosilo::Keys" do + subject.put(:test, key) + expect(adapter['test'].to_der).to eq(rsa.to_der) + end + + it "refuses to store a key with a nil id" do + expect { subject.put(nil, key) }.to raise_error(ArgumentError) + end + + it "refuses to store a key with an empty id" do + expect { subject.put('', key) }.to raise_error(ArgumentError) + end + + it "passes the Slosilo key to the adapter" do + expect(adapter).to receive(:put_key).with "test", key + subject.put :test, key + end + end +end diff --git a/gems/slosilo/spec/random_spec.rb b/gems/slosilo/spec/random_spec.rb new file mode 100644 index 0000000000..12c3184775 --- /dev/null +++ b/gems/slosilo/spec/random_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Slosilo::Random do + subject { Slosilo::Random } + let(:other_salt) { Slosilo::Random::salt } + + describe '#salt' do + subject { super().salt } + describe '#length' do + subject { super().length } + it { is_expected.to eq(32) } + end + end + + describe '#salt' do + subject { super().salt } + it { is_expected.not_to eq(other_salt) } + end +end diff --git a/gems/slosilo/spec/sequel_adapter_spec.rb b/gems/slosilo/spec/sequel_adapter_spec.rb new file mode 100644 index 0000000000..af08fbbc2e --- /dev/null +++ b/gems/slosilo/spec/sequel_adapter_spec.rb @@ -0,0 +1,171 @@ +require 'spec_helper' +require 'sequel' +require 'io/grab' + +require 'slosilo/adapters/sequel_adapter' + +describe Slosilo::Adapters::SequelAdapter do + include_context "with example key" + + let(:model) { double "model" } + before { allow(subject).to receive_messages create_model: model } + + describe "#get_key" do + context "when given key does not exist" do + before { allow(model).to receive_messages :[] => nil } + it "returns nil" do + expect(subject.get_key(:whatever)).not_to be + end + end + + context "when it exists" do + let(:id) { "id" } + before { allow(model).to receive(:[]).with(id).and_return (double "key entry", id: id, key: rsa.to_der) } + it "returns it" do + expect(subject.get_key(id)).to eq(key) + end + end + end + + describe "#put_key" do + let(:id) { "id" } + it "creates the key" do + expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der)) + allow(model).to receive_messages columns: [:id, :key] + subject.put_key id, key + end + + it "adds the fingerprint if feasible" do + expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der, :fingerprint => key.fingerprint)) + allow(model).to receive_messages columns: [:id, :key, :fingerprint] + subject.put_key id, key + end + end + + let(:adapter) { subject } + describe "#each" do + let(:one) { double("one", id: :one, key: :onek) } + let(:two) { double("two", id: :two, key: :twok) } + before { allow(model).to receive(:each).and_yield(one).and_yield(two) } + + it "iterates over each key" do + results = [] + allow(Slosilo::Key).to receive(:new) {|x|x} + adapter.each { |id,k| results << { id => k } } + expect(results).to eq([ { one: :onek}, {two: :twok } ]) + end + end + + shared_context "database" do + let(:db) { Sequel.sqlite } + before do + allow(subject).to receive(:create_model).and_call_original + Sequel::Model.cache_anonymous_models = false + Sequel::Model.db = db + end + end + + shared_context "encryption key" do + before do + Slosilo.encryption_key = Slosilo::Symmetric.new.random_key + end + end + + context "with old schema" do + include_context "encryption key" + include_context "database" + + before do + db.create_table :slosilo_keystore do + String :id, primary_key: true + bytea :key, null: false + end + subject.put_key 'test', key + end + + context "after migration" do + before { subject.migrate! } + + it "supports look up by id" do + expect(subject.get_key("test")).to eq(key) + end + + it "supports look up by fingerprint, without a warning" do + expect($stderr.grab do + expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) + end).to be_empty + end + end + + it "supports look up by id" do + expect(subject.get_key("test")).to eq(key) + end + + it "supports look up by fingerprint, but issues a warning" do + expect($stderr.grab do + expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) + end).not_to be_empty + end + end + + shared_context "current schema" do + include_context "database" + before do + Sequel.extension :migration + require 'slosilo/adapters/sequel_adapter/migration.rb' + Sequel::Migration.descendants.first.apply db, :up + end + end + + context "with current schema" do + include_context "encryption key" + include_context "current schema" + before do + subject.put_key 'test', key + end + + it "supports look up by id" do + expect(subject.get_key("test")).to eq(key) + end + + it "supports look up by fingerprint" do + expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) + end + end + + context "with an encryption key", :wip do + include_context "encryption key" + include_context "current schema" + + it { is_expected.to be_secure } + + it "saves the keys in encrypted form" do + subject.put_key 'test', key + + expect(db[:slosilo_keystore][id: 'test'][:key]).to_not eq(key.to_der) + expect(subject.get_key 'test').to eq(key) + end + end + + context "without an encryption key", :wip do + before do + Slosilo.encryption_key = nil + end + + include_context "current schema" + + it { is_expected.not_to be_secure } + + it "refuses to store a private key" do + expect { subject.put_key 'test', key }.to raise_error(Slosilo::Error::InsecureKeyStorage) + end + + it "saves the keys in plaintext form" do + pkey = key.public + subject.put_key 'test', pkey + + expect(db[:slosilo_keystore][id: 'test'][:key]).to eq(pkey.to_der) + expect(subject.get_key 'test').to eq(pkey) + end + end +end diff --git a/gems/slosilo/spec/slosilo_spec.rb b/gems/slosilo/spec/slosilo_spec.rb new file mode 100644 index 0000000000..38ed63f371 --- /dev/null +++ b/gems/slosilo/spec/slosilo_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +describe Slosilo do + include_context "with mock adapter" + include_context "with example key" + before { Slosilo['test'] = key } + + describe '[]' do + it "returns a Slosilo::Key" do + expect(Slosilo[:test]).to be_instance_of Slosilo::Key + end + + it "allows looking up by fingerprint" do + expect(Slosilo[fingerprint: key_fingerprint]).to eq(key) + end + + context "when the requested key does not exist" do + it "returns nil instead of creating a new key" do + expect(Slosilo[:aether]).not_to be + end + end + end + + describe '.sign' do + let(:own_key) { double "own key" } + before { allow(Slosilo).to receive(:[]).with(:own).and_return own_key } + let (:argument) { double "thing to sign" } + it "fetches the own key and signs using that" do + expect(own_key).to receive(:sign).with(argument) + Slosilo.sign argument + end + end + + describe '.token_valid?' do + before { allow(adapter['test']).to receive_messages token_valid?: false } + let(:key2) { double "key 2", token_valid?: false } + let(:key3) { double "key 3", token_valid?: false } + before do + adapter[:key2] = key2 + adapter[:key3] = key3 + end + + let(:token) { double "token" } + subject { Slosilo.token_valid? token } + + context "when no key validates the token" do + before { allow(Slosilo::Key).to receive_messages new: (double "key", token_valid?: false) } + it { is_expected.to be_falsey } + end + + context "when a key validates the token" do + let(:valid_key) { double token_valid?: true } + let(:invalid_key) { double token_valid?: true } + before do + allow(Slosilo::Key).to receive_messages new: invalid_key + adapter[:key2] = valid_key + end + + it { is_expected.to be_truthy } + end + end + + describe '.token_signer' do + + context "when token matches a key" do + let(:token) {{ 'data' => 'foo', 'key' => key.fingerprint, 'signature' => 'XXX' }} + + context "and the signature is valid" do + before { allow(key).to receive(:token_valid?).with(token).and_return true } + + it "returns the key id" do + expect(subject.token_signer(token)).to eq('test') + end + end + + context "and the signature is invalid" do + before { allow(key).to receive(:token_valid?).with(token).and_return false } + + it "returns nil" do + expect(subject.token_signer(token)).not_to be + end + end + end + + context "when token doesn't match a key" do + let(:token) {{ 'data' => 'foo', 'key' => "footprint", 'signature' => 'XXX' }} + it "returns nil" do + expect(subject.token_signer(token)).not_to be + end + end + + context "with JWT token" do + before do + expect(key).to receive(:validate_jwt) do |jwt| + expect(jwt.header).to eq 'kid' => key.fingerprint + expect(jwt.claims).to eq({}) + expect(jwt.signature).to eq 'sig' + end + end + + it "accepts pre-parsed JSON serialization" do + expect(Slosilo.token_signer( + 'protected' => 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=', + 'payload' => 'e30=', + 'signature' => 'c2ln' + )).to eq 'test' + end + + it "accepts pre-parsed JWT token" do + expect(Slosilo.token_signer(Slosilo::JWT( + 'protected' => 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=', + 'payload' => 'e30=', + 'signature' => 'c2ln' + ))).to eq 'test' + end + + it "accepts compact serialization" do + expect(Slosilo.token_signer( + 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=.e30=.c2ln' + )).to eq 'test' + end + end + end +end diff --git a/gems/slosilo/spec/spec_helper.rb b/gems/slosilo/spec/spec_helper.rb new file mode 100644 index 0000000000..0530f63f59 --- /dev/null +++ b/gems/slosilo/spec/spec_helper.rb @@ -0,0 +1,84 @@ +require "simplecov" +require "simplecov-cobertura" + +SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter +SimpleCov.start + +require 'slosilo' + +shared_context "with mock adapter" do + require 'slosilo/adapters/mock_adapter' + + let(:adapter) { Slosilo::Adapters::MockAdapter.new } + before { Slosilo::adapter = adapter } +end + +shared_context "with example key" do + let(:rsa) { OpenSSL::PKey::RSA.new """ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtTG/SQhW9QawP+GL6EZ5Al9gscCr7HiRO7MuQqFkaXIJD6+3 +prdHRrb0qqjNlGFgDBGAuswZ2AYqhBt7eekup+/vIpI5n04b0w+is3WwZAFco4uP +ojDeM0aY65Ar3Zgra2vWUJXRwBumroZjVBVoLJSgVfwIhwU6ORbS2oJflbtqxpuS +zkPDqS6RwEzI/DHuHTOI26fe+vfuDqGOuSR6iVI16lfvTbWwccpDwU0W9vSlyjjD +LIw0MnoKL3DHyzO66s+oNNRleMvjghQtJk/xg1kRuHReJ5/ygt2zyzdKSLeqU+T+ +TCWw/F65jrFElftexiS+g+lZC467VLCaMe1fJQIDAQABAoIBAQCiNWzXRr4CEQDL +z3Deeehu9U+tEZ1Xzv/FgD0TrUQlGc9+2YIBn+YRKkySUxfnk9zWMP0bPQiN2cdK +CQhbNSNteGCOhHVNZjGGm2K+YceNX6K9Tn1BZ5okMTlI+QIsGMQWIK316omh/58S +coCNj7R45H09PKmtpkJfRU1yDHDhqypjPDpb9/7U5mt3g2BdXYi+1hilfonHoDrC +yy3eRdf7Tlij9O3UeM+Z7pZrKATcvpDkYbNWizDITvKMYy6Ss+ajM5v7lt6QN5LP +MHjwX8Ilrxkxl0jeopr4f94tR7rNDZbLC457j8gns7cUeODtF7pPZqlrlk4KOq8Q +DvEMt2ZpAoGBAOLNUiO1SwRo75Y8ukuMVQev8O8WuzEEGINoM1lQiYlbUw3HmVp3 +iUvv58ANmKSzTXpOEZ1L8pZHSp435FrzD2WZmCAoXhNdfAXtmZA7Y46iE6BF4qrr +UegtLPhVgwpO74Y+4w2YwfDknzCOhWE4sxCbukuSvxz2pz1Vm31eFB6jAoGBAMyF +VxfYq9WhmLNsHqR+qfhb1EC5FfpSq23z/o4ryiKqCaWHimVtOO7DL7x2SK3mVNNZ +X8b4+vnJpAQ3nOxeg8fpmBaLAWYRna2AN/CYVIMKYusawhsGAlZZTu2mtJKLiOPS +8/z5dK55xJWlG5JalUB+n/4vd3WmXiT/XJj3qU+XAoGBALyHzLXeKCPcTvzmMj5G +wxAG0xMMJEMUkoP5hGXEKvBBOAMGXpXzM/Ap1s2w/6g5XDhE2SOWVGtTi9WFxI9N +6Qid6vUgWUNjvIr4/WQF2jZgyEu8jDVkM8v6cZ1lB+7zuuwvLnLI/r6ObT3h20H7 +7e3qZawYqkEbT94OYZiPMc5dAoGAHmIQtjIyFOKU1NLTGozWo1bBCXx1j2KIpSUC +RAytUsj/9d9U6Ax50L6ecNkBoxP8tgko+V4zqrgR7a51WYgQ+7nwJikwZAFp80SB +CvUWWQFKALNQ8sLJxhouZ4/Ec6DXDUFhjcthUio00iZdGjjqw1IMYq6aiJfWlJh7 +IR5pwLECgYEAyjlguks/3cjrHRF+yjonxT4tLuBI/n3TAQUPqmtkJtcwZSAJas/1 +c/twlAJ7F6n3ZroF3lgPxMJRRHZl4Z4dJsDatIxVShf3nSGg9Mi5C25obxahbv5/ +Dg1ikwi8GUF4HPZe9DyhXgDhg19wM/qcpjX8bSypsUWHWP+FanhjdWU= +-----END RSA PRIVATE KEY----- + """ } + let (:key) { Slosilo::Key.new rsa.to_der } + let (:key_fingerprint) { "107bdb8501c419fad2fdb20b467d4d0a62a16a98c35f2da0eb3b1ff929795ad9" } + + let (:another_rsa) do + OpenSSL::PKey::RSA.new """ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAryP0uGEIcDFmHDj1MjxbW+eWMeQ1k2FTKI7qx2M3MP9FR3Bz +KjFzGKnAA6QV46K/QtEt+wpWedB/bcikPXY4/vh/b2TEi8Ybw2ztT1oW9le8Djsz +3sQv5QrHsOXzSIARw4NZYxunxMFKCVC9jA8tXJb16RLgS3wAOMiPADlWIKEmPIX6 ++hg2PDgFcrCuL3XAwJ4GKy3Q5BpIFF2j+wRNfjCXDFf1bU9Gy9DND8Y50Khhw/Zn +GYN1Y3AZ3YPzz1SPf08WM663ImYwORjdkA5VlIAMKcmSStNZZUrCOo7DQjNZVD2O +vfGhGUlPqYkmTPnCG2aNP8aJm3IbF+Cb6N6PjwIDAQABAoIBAEaYtr9PlagrsV40 +81kxjR3pptgrhhEHTQ7vNOH0Mz4T16gpQrLCRgOuARE2pgAhDPlw+hjUHPFzQrpN +Ay8nJWhZYHzVYIh67ZwDn1C6HsFjshEGei0UZb3sb3v15O/Xd9GYc4KIlkKwKxjA +K/d18rH8w9kUW8bxj+FTrpjHg9kYkWGjl1WUM4o4dALVVAbbILCHKUIv3wmU5Off +oqBDunItrfVvvc9UOt1SMO15fwuZZpk0B5cjjo6+1NNpIOzqnuu48iI5dQRAIr50 +n44U4/Ix4E1p4i/9i5trCeSZRMrVxBruNxFBtCeDU6YW5fXYNBLptndfb83iqSJf +46myqakCgYEA2MAsbtOcvQv+C7KsRMQih4WqpybV/TRdeC+dZ3flPvSuI8VLJAHp +p2Tp3WXATCwgUWL/iktwWE7WFMn3VvAuMm2ITmAze/Uk71uUS5R+iaGIeRXHgd9J +fyJrIeD63ncWbb23rif2sO6zH4cp9NLS/OopHiRNlRsWEUoGpybxczMCgYEAztrf +mX4oqjqk4af4o4/UHVp3Y9lpcUXRi6dYYECoqv6wS7qCIbJkD4I4P6oTwvk25vbk +p9fwOttuqHC53/rDXVjedNe9VExIe5NhVaug1SyArw/qsafYs0QeDRBkSgCcLfP6 +LP4g824Wbv52X33BO0rJbDCICDqGDCOkqB4XcjUCgYBCkcMTxqo85ZIAxb9i31o7 +hTIEZEkUmyCZ6QXO4WPnEf7pvY52YKACaVvqQ3Xr7yF93YneT40RkiTt/ZmZeeq2 +Ui2q5KDrUT8mxFmnXNQAMTxY8/dyS8Gm6ks8/HwQF0MsMThYpK1/adBZvomER7vF +MaWvPDcXtFnytWmVrMA7QQKBgQDIHpHR4m6e+atIMIPoYR5Z44q7i7tp/ZzTGevy ++rry6wFN0jtRNE9/fYDDftwtdYL7AYKHKu7bUi0FQkFhAi39YhudOJaPNlmtTBEP +m8I2Wh6IvsJUa0jHbbAQ/Xm46kwuXOn8m0LvnuKPMRj+GyBVJ24kf/Mq2suSdO04 +RBx0vQKBgFz93G6bSzmFg0BRTqRWEXEIuYkMIZDe48OjeP4pLYH9aERsL/f/8Dyc +X2nOMv/TdLP7mvGnwCt/sQ2626DdiNqimekyBki9J2r6BzBNVmEvnLAcYaQAiQYz +ooQ2FuL0K6ukQfHPjuMswqi41lmVH8gIVqVC+QnImUCrGxH9WXWy +-----END RSA PRIVATE KEY----- + """ + end + + def self.mock_own_key + before { allow(Slosilo).to receive(:[]).with(:own).and_return key } + end +end diff --git a/gems/slosilo/spec/symmetric_spec.rb b/gems/slosilo/spec/symmetric_spec.rb new file mode 100644 index 0000000000..c3d46862ca --- /dev/null +++ b/gems/slosilo/spec/symmetric_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe Slosilo::Symmetric do + # TODO transform it to class methods only? + let(:plaintext) { "quick brown fox jumped over the lazy dog" } + let(:auth_data) { "some record id" } + let(:key) { "^\xBAIv\xDB1\x0Fi\x04\x11\xFD\x14\xA7\xCD\xDFf\x93\xFE\x93}\v\x01\x11\x98\x14\xE0;\xC1\xE2 v\xA5".force_encoding("ASCII-8BIT") } + let(:iv) { "\xD9\xABn\x01b\xFA\xBD\xC2\xE5\xEA\x01\xAC".force_encoding("ASCII-8BIT") } + let(:ciphertext) { "G^W1\x9C\xD4\xCC\x87\xD3\xFF\x86[\x0E3\xC0\xC8^\xD9\xABn\x01b\xFA\xBD\xC2\xE5\xEA\x01\xAC\x9E\xB9:\xF7\xD4ebeq\xDC \xC0sG\xA4\xAE,\xB8A|\x97\xBC\xFD\x85\xE1\xB93\x95>\xBD\n\x05\xFB\x15\x1F\x06#3M9".force_encoding('ASCII-8BIT') } + + describe '#encrypt' do + it "encrypts with AES-256-GCM" do + allow(subject).to receive_messages random_iv: iv + expect(subject.encrypt(plaintext, key: key, aad: auth_data)).to eq(ciphertext) + end + end + + describe '#decrypt' do + + it "doesn't fail when called by multiple threads" do + threads = [] + + begin + # Verify we can successfuly decrypt using many threads without OpenSSL + # errors. + 1000.times do + threads << Thread.new do + 100.times do + expect( + subject.decrypt(ciphertext, key: key, aad: auth_data) + ).to eq(plaintext) + end + end + end + ensure + threads.each(&:join) + end + end + + it "decrypts with AES-256-GCM" do + expect(subject.decrypt(ciphertext, key: key, aad: auth_data)).to eq(plaintext) + end + + + context "when the ciphertext has been messed with" do + let(:ciphertext) { "pwnd!" } # maybe we should do something more realistic like add some padding? + it "raises an exception" do + expect{ subject.decrypt(ciphertext, key: key, aad: auth_data)}.to raise_exception /Invalid version/ + end + context "by adding a trailing 0" do + let(:new_ciphertext){ ciphertext + '\0' } + it "raises an exception" do + expect{ subject.decrypt(new_ciphertext, key: key, aad: auth_data) }.to raise_exception /Invalid version/ + end + end + end + + context "when no auth_data is given" do + let(:auth_data){""} + let(:ciphertext){ "Gm\xDAT\xE8I\x9F\xB7\xDC\xBB\x84\xD3Q#\x1F\xF4\x8C\aV\x93\x8F_\xC7\xBC87\xC9U\xF1\xAF\x8A\xD62\x1C5H\x86\x17\x19=B~Y*\xBC\x9D\eJeTx\x1F\x02l\t\t\xD3e\xA4\x11\x13y*\x95\x9F\xCD\xC4@\x9C"} + + it "decrypts the message" do + expect(subject.decrypt(ciphertext, key: key, aad: auth_data)).to eq(plaintext) + end + + context "and the ciphertext has been messed with" do + it "raises an exception" do + expect{ subject.decrypt(ciphertext + "\0\0\0", key: key, aad: auth_data)}.to raise_exception OpenSSL::Cipher::CipherError + end + end + end + + context "when the auth data doesn't match" do + let(:auth_data){ "asdf" } + it "raises an exception" do + expect{ subject.decrypt(ciphertext, key: key, aad: auth_data)}.to raise_exception OpenSSL::Cipher::CipherError + end + end + end + + describe '#random_iv' do + it "generates a random iv" do + expect_any_instance_of(OpenSSL::Cipher).to receive(:random_iv).and_return :iv + expect(subject.random_iv).to eq(:iv) + end + end + + describe '#random_key' do + it "generates a random key" do + expect_any_instance_of(OpenSSL::Cipher).to receive(:random_key).and_return :key + expect(subject.random_key).to eq(:key) + end + end +end diff --git a/gems/slosilo/test.sh b/gems/slosilo/test.sh new file mode 100755 index 0000000000..bd6a15484d --- /dev/null +++ b/gems/slosilo/test.sh @@ -0,0 +1,27 @@ +#!/bin/bash -xe + +iid=slosilo-test-$(date +%s) + +docker build -t $iid -f - . << EOF + FROM ruby:3.0 + WORKDIR /app + COPY Gemfile slosilo.gemspec ./ + RUN bundle + COPY . ./ + RUN bundle +EOF + +cidfile=$(mktemp -u) +docker run --cidfile $cidfile -v /app/spec/reports $iid bundle exec rake jenkins || : + +cid=$(cat $cidfile) + +docker cp $cid:/app/spec/reports spec/ +docker cp $cid:/app/coverage spec + +docker rm $cid + +# untag, will use cache next time if available but no junk will be left +docker rmi $iid + +rm $cidfile From 2dac589cd0a965de7ba6bfc88ec008dda8f26172 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 21 Jun 2023 12:55:18 +0300 Subject: [PATCH 045/665] Modify changelog and jenkins file --- CHANGELOG.md | 3 ++- Jenkinsfile | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31eddf4aa6..f4257d323e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.1-cloud] - 2023-06-08 +## [1.0.1-cloud] - 2023-06-21 ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 +- Pull Slosilo library to Conjur ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/Jenkinsfile b/Jenkinsfile index d72b15efa1..2eddee6ebb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -223,6 +223,7 @@ pipeline { spec/reports/*.xml, spec/reports-audit/*.xml, gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml, cucumber/*/features/reports/**/*.xml ''' ) @@ -578,10 +579,12 @@ pipeline { spec/reports/*.xml, spec/reports-audit/*.xml, gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml cucumber/*/features/reports/**/*.xml, ee-test/spec/reports/*.xml, ee-test/spec/reports-audit/*.xml, ee-test/gems/conjur-rack/spec/reports/*.xml, + ee-test/gems/slosilo/spec/reports/*.xml, ee-test/cucumber/*/features/reports/**/*.xml ''' ) @@ -702,6 +705,11 @@ def runConjurTests(run_only_str) { "Rack - ${env.STAGE_NAME}": { sh 'cd gems/conjur-rack && ./test.sh' } + ], + "slosilo": [ + "Slosilo - ${env.STAGE_NAME}": { + sh 'cd gems/slosilo && ./test.sh' + } ] ] From 1c3c799eb9403344494c14c7c9c94044f14245e2 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 14 Jun 2023 12:13:20 +0300 Subject: [PATCH 046/665] Add :current to slosilo id - db migrate --- .../20230613064747_update_prev_curr_slos_key.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 db/migrate/20230613064747_update_prev_curr_slos_key.rb diff --git a/db/migrate/20230613064747_update_prev_curr_slos_key.rb b/db/migrate/20230613064747_update_prev_curr_slos_key.rb new file mode 100644 index 0000000000..fc533120ed --- /dev/null +++ b/db/migrate/20230613064747_update_prev_curr_slos_key.rb @@ -0,0 +1,15 @@ + +# Slosilo key id changed from authn:account:host/user to authn:account:host/user:current/previous +# This migration should run only in existing tenants that include authn:account:host/user keys + +Sequel.migration do + up do + # update only if 'authn:conjur:host' or 'authn:conjur:user' accounts exists + if Slosilo['authn:conjur:host'] || Slosilo['authn:conjur:user'] + Rake::Task['slosilo:generate'].execute(name: 'authn:conjur:host:current') + Rake::Task['slosilo:generate'].execute(name: 'authn:conjur:user:current') + run("DELETE FROM slosilo_keystore WHERE (id = 'authn:conjur:host');") + run("DELETE FROM slosilo_keystore WHERE (id = 'authn:conjur:user');") + end + end +end \ No newline at end of file From 42e3574509be18fcc3ca1ac772efc0afefde04b0 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 14 Jun 2023 12:13:48 +0300 Subject: [PATCH 047/665] Add :current to slosilo id --- CHANGELOG.md | 1 + app/models/account.rb | 2 +- .../features/support/hooks.rb | 8 ++------ cucumber/_common/slosilo_helper.rb | 13 +++++++++++++ cucumber/api/features/support/env.rb | 4 ++-- cucumber/api/features/support/hooks.rb | 9 +++------ cucumber/api/features/support/rest_helpers.rb | 2 +- cucumber/authenticators/features/support/hooks.rb | 8 +++----- cucumber/policy/features/support/hooks.rb | 7 ++----- gems/conjur-rack/spec/rack/authenticator_spec.rb | 12 ++++++------ spec/app/domain/token_factory_spec.rb | 5 +++-- spec/support/slosilo_helper.rb | 2 +- 12 files changed, 38 insertions(+), 35 deletions(-) create mode 100644 cucumber/_common/slosilo_helper.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f4257d323e..a42cc6be60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 - Pull Slosilo library to Conjur +- Change Slosilo id from "authn:account:host/user" to "authn:account:host/user:current" ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/app/models/account.rb b/app/models/account.rb index f9df2fbd89..ee5b29e652 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -23,7 +23,7 @@ def token_key(account, role) end def token_id(account, role) - "authn:#{account}:#{role}" + "authn:#{account}:#{role}:current" end def create(id, owner_id = nil) diff --git a/cucumber/_authenticators_common/features/support/hooks.rb b/cucumber/_authenticators_common/features/support/hooks.rb index 562e6d3cb4..80e4e0ba86 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'cucumber/_common/slosilo_helper' Before('@skip') do skip_this_scenario @@ -15,12 +16,7 @@ Role.truncate(cascade: true) Secret.truncate Credentials.truncate - - Slosilo.each do |k, _| - unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end + init_slosilo_keys Account.find_or_create_accounts_resource admin_role = Role.create(role_id: "cucumber:user:admin") diff --git a/cucumber/_common/slosilo_helper.rb b/cucumber/_common/slosilo_helper.rb new file mode 100644 index 0000000000..9e37c47de0 --- /dev/null +++ b/cucumber/_common/slosilo_helper.rb @@ -0,0 +1,13 @@ + +def token_id(account, role) + "authn:#{account}:#{role}:current" +end + +def init_slosilo_keys + slosilo_ids = [token_id("rspec", "host"), token_id("rspec", "user"), token_id("cucumber", "host"), token_id("cucumber", "user")] + Slosilo.each do |k, v| + unless slosilo_ids.member?(k) + Slosilo.send(:keystore).adapter.model[k].delete + end + end +end \ No newline at end of file diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index 9d0b141c06..f81b2a694a 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -17,7 +17,7 @@ # per Rafal's request. It could be deleted were it not for that. ENV['CONJUR_APPLIANCE_URL'] ||= Utils.start_local_server -Slosilo["authn:cucumber:user"] ||= Slosilo::Key.new -Slosilo["authn:cucumber:host"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:user:current"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:host:current"] ||= Slosilo::Key.new JsonSpec.excluded_keys = %w[created_at updated_at] diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index de52cd62cd..13e05ec8ca 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -4,6 +4,7 @@ # require 'haikunator' require 'fileutils' +require 'cucumber/_common/slosilo_helper' # Reset the DB between each test # @@ -17,12 +18,8 @@ Secret.truncate Credentials.truncate - Slosilo.each do |k,v| - unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end - + init_slosilo_keys + Account.find_or_create_accounts_resource admin_role = Role.create(role_id: "cucumber:user:admin") Credentials.new(role: admin_role).save(raise_on_save_failure: true) diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index 8a766a436a..7a80d7a09b 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -289,7 +289,7 @@ def current_user_api_key def current_user_credentials username = @current_user.login # Configure Slosilo to produce valid access tokens - slosilo_user = Slosilo["authn:#{@current_user.account}:user"] ||= Slosilo::Key.new + slosilo_user = Slosilo["authn:#{@current_user.account}:user:current"] ||= Slosilo::Key.new # NOTE: 'iat' (issueat) is expected to be autogenerated token = slosilo_user.issue_jwt(sub: username) user_credentials(username, token) diff --git a/cucumber/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index fd20be26a5..3a3c27e928 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -4,6 +4,8 @@ # # Prior to this hook, our tests had hidden coupling. This ensures each test is # run independently. + +require 'cucumber/_common/slosilo_helper' Before do @user_index = 0 @@ -11,11 +13,7 @@ Secret.truncate Credentials.truncate - Slosilo.each do |k, _| - unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end + init_slosilo_keys admin_role = Role.create(role_id: "cucumber:user:admin") creds = Credentials.new(role: admin_role) diff --git a/cucumber/policy/features/support/hooks.rb b/cucumber/policy/features/support/hooks.rb index 51d0decbcc..efa7add043 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -4,6 +4,7 @@ # require 'haikunator' require 'fileutils' +require 'cucumber/_common/slosilo_helper' Before do |scenario| @scenario_name = scenario.name @@ -24,11 +25,7 @@ Secret.truncate Credentials.truncate - Slosilo.each do |k, v| - unless %w[authn:rspec:user authn:rspec:host authn:cucumber:user authn:cucumber:host].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end + init_slosilo_keys Account.find_or_create_accounts_resource admin_role = Role.create(role_id: "cucumber:user:admin") diff --git a/gems/conjur-rack/spec/rack/authenticator_spec.rb b/gems/conjur-rack/spec/rack/authenticator_spec.rb index 2ef4ae4b92..00b0a58719 100644 --- a/gems/conjur-rack/spec/rack/authenticator_spec.rb +++ b/gems/conjur-rack/spec/rack/authenticator_spec.rb @@ -190,19 +190,19 @@ end end - context "with 'authn:test:user' token signer" do + context "with 'authn:test:host:current' token signer" do it "returns test account name" do - token = mock_jwt({sub: 'user'}) - allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:user') + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:host:current') res = subject.send(:validate_token_and_get_account, token) expect(res).to eq("test") end end - context "with 'authn:test:host' token signer" do + context "with 'authn:test:user:current' token signer" do it "returns test account name" do - token = mock_jwt({sub: 'host/host'}) - allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:host') + token = mock_jwt({sub: 'user'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:user:current') res = subject.send(:validate_token_and_get_account, token) expect(res).to eq("test") end diff --git a/spec/app/domain/token_factory_spec.rb b/spec/app/domain/token_factory_spec.rb index 1a41f9c6d6..80180bea0f 100644 --- a/spec/app/domain/token_factory_spec.rb +++ b/spec/app/domain/token_factory_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'support/slosilo_helper' describe TokenFactory do @@ -95,8 +96,8 @@ context 'User and Host Key doesnt exists in db' do it 'Raises error' do account = "cucumber2" - expect{token_factory.signing_key("myuser", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account 'authn:#{account}:user'") - expect{token_factory.signing_key("host/myhost", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account 'authn:#{account}:host'") + expect{token_factory.signing_key("myuser", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account '#{token_id(account, "user")}'") + expect{token_factory.signing_key("host/myhost", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account '#{token_id(account, "host")}'") end end end diff --git a/spec/support/slosilo_helper.rb b/spec/support/slosilo_helper.rb index 874cf5f551..d1f91e6ada 100644 --- a/spec/support/slosilo_helper.rb +++ b/spec/support/slosilo_helper.rb @@ -5,7 +5,7 @@ def token_key(account, role) end def token_id(account, role) - "authn:#{account}:#{role}" + "authn:#{account}:#{role}:current" end def init_slosilo_keys(account) From 4806f5d76604c183a35b86a26cb618c8b7c49afb Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 21 Jun 2023 15:53:23 +0300 Subject: [PATCH 048/665] Add update slosilo key --- CHANGELOG.md | 1 + .../lib/slosilo/adapters/sequel_adapter.rb | 7 ++++++- gems/slosilo/spec/sequel_adapter_spec.rb | 11 +++++++++++ spec/rack/slosilo.rb | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 spec/rack/slosilo.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a42cc6be60..f5f3c698a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 - Pull Slosilo library to Conjur - Change Slosilo id from "authn:account:host/user" to "authn:account:host/user:current" +- Add update slosilo key option to slosilo put key function ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb b/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb index b52a75bab6..08a7792074 100644 --- a/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb +++ b/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb @@ -23,7 +23,12 @@ def put_key id, value attrs = { id: id, key: value.to_der } attrs[:fingerprint] = value.fingerprint if fingerprint_in_db? - model.create attrs + stored = model[id] + if stored + stored.update attrs + else + model.create attrs + end end def get_key id diff --git a/gems/slosilo/spec/sequel_adapter_spec.rb b/gems/slosilo/spec/sequel_adapter_spec.rb index af08fbbc2e..6625f79168 100644 --- a/gems/slosilo/spec/sequel_adapter_spec.rb +++ b/gems/slosilo/spec/sequel_adapter_spec.rb @@ -31,15 +31,26 @@ let(:id) { "id" } it "creates the key" do expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der)) + expect(model).to receive(:[]).with(id).and_return(nil) allow(model).to receive_messages columns: [:id, :key] subject.put_key id, key end it "adds the fingerprint if feasible" do expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der, :fingerprint => key.fingerprint)) + expect(model).to receive(:[]).with(id).and_return(nil) allow(model).to receive_messages columns: [:id, :key, :fingerprint] subject.put_key id, key end + + it "update existing key" do + hash = hash_including(:id => id, :key => another_key.to_der) + expect(model).to receive(:[]).with(id).and_return(hash) + expect(hash).to receive(:update).with(hash) + allow(model).to receive_messages columns: [:id, :another_key] + subject.put_key id, key + end + end let(:adapter) { subject } diff --git a/spec/rack/slosilo.rb b/spec/rack/slosilo.rb new file mode 100644 index 0000000000..56efac67ad --- /dev/null +++ b/spec/rack/slosilo.rb @@ -0,0 +1,15 @@ +require 'spec_helper' +describe "Slosilo key" do + before(:all) { + init_slosilo_keys("rspec") + } + context "Update existing key" do + it "possible to update existing key" do + new_key = Slosilo::Key.new + Slosilo["authn:rspec:host:current"] = new_key + current_key = Slosilo["authn:rspec:host:current"] + expect(current_key.to_der.unpack("H*")[0]).to eq(new_key.to_der.unpack("H*")[0]) + expect(current_key.fingerprint).to eq(new_key.fingerprint) + end + end +end \ No newline at end of file From 72ed49d36917db1d7761ce5987be7aea0119366f Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Tue, 4 Jul 2023 13:14:24 +0300 Subject: [PATCH 049/665] fix slosilo test --- gems/slosilo/spec/sequel_adapter_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gems/slosilo/spec/sequel_adapter_spec.rb b/gems/slosilo/spec/sequel_adapter_spec.rb index 6625f79168..7320e14c19 100644 --- a/gems/slosilo/spec/sequel_adapter_spec.rb +++ b/gems/slosilo/spec/sequel_adapter_spec.rb @@ -44,10 +44,10 @@ end it "update existing key" do - hash = hash_including(:id => id, :key => another_key.to_der) + hash = hash_including(:id => id, :key => key.to_der) expect(model).to receive(:[]).with(id).and_return(hash) expect(hash).to receive(:update).with(hash) - allow(model).to receive_messages columns: [:id, :another_key] + allow(model).to receive_messages columns: [:id, :key] subject.put_key id, key end From b426d5bbdc928c822b472b73bc663a5ff42bab30 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 3 Jul 2023 17:54:45 +0300 Subject: [PATCH 050/665] Rotate slosilo scheduled task --- CHANGELOG.md | 1 + Gemfile | 1 + Gemfile.lock | 9 ++++ app/domain/errors.rb | 5 ++ app/domain/logs.rb | 5 ++ app/models/activity_log.rb | 3 ++ config/initializers/scheduler.rb | 14 ++++++ cucumber.yml | 4 ++ .../step_definitions/secrets_steps.rb | 9 ++++ .../features/slosilo_rotate_key.feature | 38 +++++++++++++++ .../step_definitions/rotator_steps.rb | 8 +++- .../20230614133003_create_activity_log.rb | 12 +++++ .../spec/rack/authenticator_spec.rb | 18 +++++++ lib/conjur/conjur_config.rb | 3 +- lib/tasks/rotate.rake | 38 +++++++++++++++ spec/lib/tasks/rotate_spec.rb | 47 +++++++++++++++++++ spec/rack/slosilo.rb | 4 +- 17 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 app/models/activity_log.rb create mode 100644 config/initializers/scheduler.rb create mode 100644 cucumber/rotators/features/slosilo_rotate_key.feature create mode 100644 db/migrate/20230614133003_create_activity_log.rb create mode 100644 lib/tasks/rotate.rake create mode 100644 spec/lib/tasks/rotate_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f3c698a7..08beab64f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Pull Slosilo library to Conjur - Change Slosilo id from "authn:account:host/user" to "authn:account:host/user:current" - Add update slosilo key option to slosilo put key function +- Add slosilo key rotation scheduled task ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/Gemfile b/Gemfile index 482c97d308..ac6d8d951c 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem 'puma', '~> 5.6' gem 'rack', '~> 2.2' gem 'rails', '~> 6.1', '>= 6.1.4.6' gem 'rake' +gem 'rufus-scheduler' gem 'pg' gem 'sequel' diff --git a/Gemfile.lock b/Gemfile.lock index 9c0302cbc5..77a6e239c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -213,6 +213,8 @@ GEM dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.0, >= 1.0.2) erubi (1.12.0) + et-orbi (1.2.7) + tzinfo event_emitter (0.2.6) eventmachine (1.2.7) excon (0.91.0) @@ -223,6 +225,9 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + fugit (1.8.1) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) gli (2.21.0) globalid (1.1.0) activesupport (>= 5.0) @@ -329,6 +334,7 @@ GEM public_suffix (4.0.6) puma (5.6.4) nio4r (~> 2.0) + raabro (1.4.0) racc (1.6.2) rack (2.2.6.4) rack-oauth2 (1.19.0) @@ -428,6 +434,8 @@ GEM rake (>= 0.8.1) ruby-next-core (0.14.0) ruby-progressbar (1.11.0) + rufus-scheduler (3.9.1) + fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) sequel (5.51.0) sequel-pg_advisory_locking (1.0.1) @@ -560,6 +568,7 @@ DEPENDENCIES rubocop (~> 0.58.0) rubocop-checkstyle_formatter ruby-debug-ide + rufus-scheduler sequel sequel-pg_advisory_locking sequel-postgres-schemata diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 78aa273757..a339a79a05 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -57,6 +57,11 @@ module Conjur msg: "Resource '{0-resource}' requested by role '{1-role}' not found", code: "CONJ00123E" ) + + FailedRotateSlosilo = ::Util::TrackableErrorClass.new( + msg: "Failed to rotate Sloislo key. Reason: '{0}'", + code: "CONJ00157E" + ) end module Authorization diff --git a/app/domain/logs.rb b/app/domain/logs.rb index d0543fba8f..c06039663f 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -23,6 +23,11 @@ module Conjur msg: "OpenSSL FIPS mode set to {0}", code: "CONJ00038I" ) + + SlosiloRotate = ::Util::TrackableLogMessageClass.new( + msg: "Slosilo key is rotated successfully", + code: "CONJ00156I" + ) end module Endpoints diff --git a/app/models/activity_log.rb b/app/models/activity_log.rb new file mode 100644 index 0000000000..0d90aa9fa8 --- /dev/null +++ b/app/models/activity_log.rb @@ -0,0 +1,3 @@ +class ActivityLog < Sequel::Model(:activity_log) + def_column_alias :id, :activity_id +end diff --git a/config/initializers/scheduler.rb b/config/initializers/scheduler.rb new file mode 100644 index 0000000000..3654b24965 --- /dev/null +++ b/config/initializers/scheduler.rb @@ -0,0 +1,14 @@ +require 'rufus-scheduler' + +# Do not schedule when Rails is run from a test/spec +return if Rails.env.test? + +scheduler = Rufus::Scheduler.singleton(lockfile: ".slosilo-rotation-rufus-scheduler.lock") +interval = Rails.application.config.conjur_config.slosilo_rotation_interval + +unless scheduler.down? + # Schedule task one second after startup and every interval + scheduler.every "#{interval}h", first_in: 1.second.since do + system("rake rotate:slosilo") + end +end diff --git a/cucumber.yml b/cucumber.yml index 25d199f682..91a124072e 100644 --- a/cucumber.yml +++ b/cucumber.yml @@ -155,10 +155,14 @@ rotators: > -r cucumber/authenticators/features/support/hooks.rb -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb + -r cucumber/api/features/support/authz_helpers.rb + -r cucumber/api/features/step_definitions/authz_steps.rb -r cucumber/api/features/step_definitions/request_steps.rb -r cucumber/api/features/step_definitions/user_steps.rb -r cucumber/policy/features/step_definitions/policy_steps.rb -r cucumber/policy/features/support/policy_helpers.rb + -r cucumber/policy/features/step_definitions/login_steps.rb + -r cucumber/policy/features/step_definitions/secrets_steps.rb -r cucumber/rotators cucumber/rotators diff --git a/cucumber/policy/features/step_definitions/secrets_steps.rb b/cucumber/policy/features/step_definitions/secrets_steps.rb index ba35d55a62..801e9e8dcc 100644 --- a/cucumber/policy/features/step_definitions/secrets_steps.rb +++ b/cucumber/policy/features/step_definitions/secrets_steps.rb @@ -8,6 +8,15 @@ expect(resp.code).to eq(expected_status) end +When(/^I add a secret to ([\w_]+) resource "([^"]*)"$/) do |_kind, id| + @random_secret = SecureRandom.uuid + @resp = @client.add_secret(id: id, value: @random_secret) +end + +Then(/^The response status code is (\d+)$/) do |code| + expect(@resp.code).to eq(code.to_i) +end + # TODO: kind is now superfluous. It is never used, since it's always "variable" Then(/^I can( not)? add a provider-url to ([\w_]+) resource "([^"]*)"$/) do |fail, _kind, id| expected_status = fail ? 403 : 201 diff --git a/cucumber/rotators/features/slosilo_rotate_key.feature b/cucumber/rotators/features/slosilo_rotate_key.feature new file mode 100644 index 0000000000..1d6dff8efd --- /dev/null +++ b/cucumber/rotators/features/slosilo_rotate_key.feature @@ -0,0 +1,38 @@ +@rotators +Feature: Rotate Slosilo key. When rotation occurs twice in a row, + the user is logged out (token is not valid anymore) and should log in again. + Scenario: Logged in as user, Slosilo is rotated twice + Given I load a policy: + """ + - !user alice + - !variable + id: db-password + owner: !user alice + """ + And I log in as user "alice" + Then I can add a secret to variable resource "db-password" + When Slosilo key is rotated + Then I can add a secret to variable resource "db-password" + When Slosilo key is rotated + And I add a secret to variable resource "db-password" + Then The response status code is 401 + Given I log in as user "alice" + Then I can add a secret to variable resource "db-password" + + Scenario: Logged in as host, Slosilo is rotated twice + Given I load a policy: + """ + - !host myapp + - !variable + id: app-password + owner: !host myapp + """ + And I log in as host "myapp" + Then I can add a secret to variable resource "app-password" + When Slosilo key is rotated + Then I can add a secret to variable resource "app-password" + When Slosilo key is rotated + And I add a secret to variable resource "app-password" + Then The response status code is 401 + Given I log in as host "myapp" + Then I can add a secret to variable resource "app-password" diff --git a/cucumber/rotators/features/step_definitions/rotator_steps.rb b/cucumber/rotators/features/step_definitions/rotator_steps.rb index 18ce8c7089..a65255e7ab 100644 --- a/cucumber/rotators/features/step_definitions/rotator_steps.rb +++ b/cucumber/rotators/features/step_definitions/rotator_steps.rb @@ -118,8 +118,14 @@ @client.add_secret(conjur_varname, val) end - Then(/^I wait for (\d+) seconds?$/) do |num_seconds| puts "Sleeping #{num_seconds}...." sleep(num_seconds.to_i) end + +When(/^Slosilo key is( not)? rotated$/) do |not_rotated| + update_time = not_rotated ? Time.now : Rails.application.config.conjur_config.slosilo_rotation_interval.hours.ago + ActivityLog["last_slosilo_update"].update({timestamp: update_time}) + command = ["rake", "rotate:slosilo[cucumber]"] + system(*command) +end diff --git a/db/migrate/20230614133003_create_activity_log.rb b/db/migrate/20230614133003_create_activity_log.rb new file mode 100644 index 0000000000..711cbdffb0 --- /dev/null +++ b/db/migrate/20230614133003_create_activity_log.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table :activity_log do + String :activity_id, primary_key: true + Timestamp :timestamp, null: false + end + + self[:activity_log].insert(activity_id: 'last_slosilo_update', timestamp: 100.years.ago) + end +end diff --git a/gems/conjur-rack/spec/rack/authenticator_spec.rb b/gems/conjur-rack/spec/rack/authenticator_spec.rb index 00b0a58719..ec3a02de93 100644 --- a/gems/conjur-rack/spec/rack/authenticator_spec.rb +++ b/gems/conjur-rack/spec/rack/authenticator_spec.rb @@ -190,6 +190,24 @@ end end + context "with 'authn:test:user:previous' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'user'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:user:previous') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end + end + + context "with 'authn:test:host:previous' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:host:previous') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end + end + context "with 'authn:test:host:current' token signer" do it "returns test account name" do token = mock_jwt({sub: 'host/host'}) diff --git a/lib/conjur/conjur_config.rb b/lib/conjur/conjur_config.rb index 8d81268d8c..395b2acc9e 100644 --- a/lib/conjur/conjur_config.rb +++ b/lib/conjur/conjur_config.rb @@ -38,7 +38,8 @@ class ConjurConfig < Anyway::Config host_authorization_token_ttl: 480, # The default TTL of Host is 8 minutes authn_api_key_default: true, authenticators: [], - extensions: [] + extensions: [], + slosilo_rotation_interval: 24 # Sloislo rotation should be every 24 hours ) def initialize( diff --git a/lib/tasks/rotate.rake b/lib/tasks/rotate.rake new file mode 100644 index 0000000000..194b79d9f2 --- /dev/null +++ b/lib/tasks/rotate.rake @@ -0,0 +1,38 @@ +# This rake task is scheduled once in 24 hours. +# It rotates current slosilo key with previous, so token that are signed with previous key can still be validated. + +desc "rotate sloislo key" +namespace :rotate do + task :"slosilo", [:account] => :environment do |t, args| + account = args[:account] || "conjur" + id = -> (account, type) { return "authn:#{account}:#{type}" } + unless Slosilo["#{id.call(account, "host")}:current"] || Slosilo["#{id.call(account, "user")}:current"] + Rails.logger.info(Errors::Conjur::FailedRotateSlosilo.new("Slosilo keys weren't found in db")) + abort + end + begin + Sequel::Model.db.transaction do + last_update = ActivityLog["last_slosilo_update"].lock! + last_update_time = last_update.timestamp + # This tasks runs in all Conjur instances, thus, we check if it already run in the last 24 hours. + if last_update_time < Rails.application.config.conjur_config.slosilo_rotation_interval.hours.ago + # rotate users key + rotate_slosilo(id.call(account, "user")) + # rotate hosts key + rotate_slosilo(id.call(account, "host")) + ActivityLog["last_slosilo_update"].update({timestamp: Time.now}) + Rails.logger.info(LogMessages::Conjur::SlosiloRotate.new()) + end + end + rescue => e + # Handle the lock failure + Rails.logger.info(Errors::Conjur::FailedRotateSlosilo.new(e.message)) + end + end + + def rotate_slosilo(id) + prev = Slosilo["#{id}:current"] + Slosilo["#{id}:current"] = Slosilo::Key.new + Slosilo["#{id}:previous"] = prev + end +end diff --git a/spec/lib/tasks/rotate_spec.rb b/spec/lib/tasks/rotate_spec.rb new file mode 100644 index 0000000000..eba7cebdee --- /dev/null +++ b/spec/lib/tasks/rotate_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' +Rails.application.load_tasks +require 'support/slosilo_helper' + +describe "rotate:slosilo" do + id = -> (account, type) { return "authn:#{account}:#{type}" } + before(:context) do + init_slosilo_keys("rspec") + ActivityLog["last_slosilo_update"] || ActivityLog.create(id: "last_slosilo_update", timestamp: 100.years.ago) + end + + context "rotate key rake" do + it "rotate key when last time was more then slosilo_rotation_interval hours ago" do + ActivityLog["last_slosilo_update"].update({ timestamp: Time.now - Rails.application.config.conjur_config.slosilo_rotation_interval.hours - 1.hours }) + host_key = Slosilo["#{id.call("rspec", "host")}:current"] + user_key = Slosilo["#{id.call("rspec", "user")}:current"] + last_timestamp = ActivityLog["last_slosilo_update"].timestamp + Rake::Task["rotate:slosilo"].execute(account: "rspec") + + host_key_prev = Slosilo["#{id.call("rspec", "host")}:previous"] + user_key_prev = Slosilo["#{id.call("rspec", "user")}:previous"] + expect(host_key.fingerprint).to eq(host_key_prev.fingerprint) + expect(user_key.fingerprint).to eq(user_key_prev.fingerprint) + expect(host_key.key.to_der).to eq(host_key_prev.key.to_der) + expect(user_key.key.to_der).to eq(user_key_prev.key.to_der) + expect(ActivityLog["last_slosilo_update"].timestamp).to be > last_timestamp + end + + it "do not rotate key when last time was less then slosilo_rotation_interval hours ago" do + ActivityLog["last_slosilo_update"].update({timestamp: Time.now - Rails.application.config.conjur_config.slosilo_rotation_interval.hours + 1.hours }) + Slosilo["#{id.call("rspec", "host")}:previous"] ||= Slosilo::Key.new + Slosilo["#{id.call("rspec", "user")}:previous"] ||= Slosilo::Key.new + host_key = Slosilo["#{id.call("rspec", "host")}:current"] + user_key = Slosilo["#{id.call("rspec", "user")}:current"] + last_timestamp = ActivityLog["last_slosilo_update"].timestamp + Rake::Task["rotate:slosilo"].execute(account: "rspec") + host_key_prev = Slosilo["#{id.call("rspec", "host")}:previous"] + user_key_prev = Slosilo["#{id.call("rspec", "user")}:previous"] + + expect(host_key.fingerprint).to_not eq(host_key_prev.fingerprint) + expect(user_key.fingerprint).to_not eq(user_key_prev.fingerprint) + expect(host_key.key.to_der).to_not eq(host_key_prev.key.to_der) + expect(user_key.key.to_der).to_not eq(user_key_prev.key.to_der) + expect(ActivityLog["last_slosilo_update"].timestamp).to eq(last_timestamp) + end + end +end diff --git a/spec/rack/slosilo.rb b/spec/rack/slosilo.rb index 56efac67ad..02bc17dca1 100644 --- a/spec/rack/slosilo.rb +++ b/spec/rack/slosilo.rb @@ -8,8 +8,8 @@ new_key = Slosilo::Key.new Slosilo["authn:rspec:host:current"] = new_key current_key = Slosilo["authn:rspec:host:current"] - expect(current_key.to_der.unpack("H*")[0]).to eq(new_key.to_der.unpack("H*")[0]) + expect(current_key.to_der).to eq(new_key.to_der) expect(current_key.fingerprint).to eq(new_key.fingerprint) end end -end \ No newline at end of file +end From 5593d025df42d0ee3b34be9538b5ec3ab01e9339 Mon Sep 17 00:00:00 2001 From: ygeva Date: Thu, 29 Jun 2023 16:16:56 +0300 Subject: [PATCH 051/665] Add workload endpoint --- CHANGELOG.md | 4 + .../concerns/find_policy_resource.rb | 18 ++ app/controllers/hosts_controller.rb | 59 +++++ app/controllers/wrappers/policy_audit.rb | 22 ++ app/controllers/wrappers/policy_wrapper.rb | 53 ++++ .../wrappers/templates_renderer.rb | 9 + app/domain/policy-templates/base_template.rb | 9 + .../policy-templates/hosts/create_host.rb | 33 +++ config/routes.rb | 2 + .../policy-templates/base_template_spec.rb | 11 + .../concerns/find_policy_resource_spec.rb | 28 +++ spec/controllers/hosts_controller_spec.rb | 235 ++++++++++++++++++ .../wrappers/template_renderer_spec.rb | 44 ++++ 13 files changed, 527 insertions(+) create mode 100644 app/controllers/concerns/find_policy_resource.rb create mode 100644 app/controllers/hosts_controller.rb create mode 100644 app/controllers/wrappers/policy_audit.rb create mode 100644 app/controllers/wrappers/policy_wrapper.rb create mode 100644 app/controllers/wrappers/templates_renderer.rb create mode 100644 app/domain/policy-templates/base_template.rb create mode 100644 app/domain/policy-templates/hosts/create_host.rb create mode 100644 spec/app/domain/policy-templates/base_template_spec.rb create mode 100644 spec/controllers/concerns/find_policy_resource_spec.rb create mode 100644 spec/controllers/hosts_controller_spec.rb create mode 100644 spec/controllers/wrappers/template_renderer_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 08beab64f0..fa550c7e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.2-cloud] - 2023-07-16 +### Changed +- Add workload create endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41138 + ## [1.0.1-cloud] - 2023-06-21 ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 diff --git a/app/controllers/concerns/find_policy_resource.rb b/app/controllers/concerns/find_policy_resource.rb new file mode 100644 index 0000000000..1057afecdd --- /dev/null +++ b/app/controllers/concerns/find_policy_resource.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +module FindPolicyResource + include FindResource + extend ActiveSupport::Concern + + def resource_id + [ params[:account], "policy", params[:identifier] ].join(":") + end + + def find_or_create_root_policy + Loader::Types.find_or_create_root_policy(account) + end + + def account + @account ||= params[:account] + end + +end \ No newline at end of file diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb new file mode 100644 index 0000000000..dc23373462 --- /dev/null +++ b/app/controllers/hosts_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +require_relative '../controllers/wrappers/templates_renderer' +# +class HostsController < RestController + include AuthorizeResource + include PolicyAudit + include PolicyWrapper + include PolicyTemplates::TemplatesRenderer + include BodyParser + include FindPolicyResource + + before_action :current_user + before_action :find_or_create_root_policy + + rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load + + set_default_content_type_for_path(%r{^/hosts}, 'application/json') + + def post + logger.info(LogMessages::Endpoints::EndpointRequested.new("hosts/:account/*identifier")) + action = :create + authorize(action, resource) + params.permit(:identifier, :account, :id, :annotations, :groups, :layers) + .to_h.symbolize_keys + validateId(params[:id]) + input = input_post_yaml(params) + result_yaml = renderer(PolicyTemplates::CreateHost.new(), input) + set_raw_policy(result_yaml) + result = load_policy(Loader::CreatePolicy, false) + policy = result[:policy] + audit_success(policy) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("hosts/:account/*identifier")) + render(json: { + created_roles: result[:created_roles] + }, status: :created) + rescue => e + audit_failure(e, action) + raise e + end +end + +private + +def validateId(id) + if id.nil? || id.empty? + raise ApplicationController::UnprocessableEntity, "id param is missing in body, must not be blank." + end +end + +def input_post_yaml(json_body) + return input = { + "id" => json_body[:id], + "annotations" => json_body[:annotations], + "groups" => json_body[:groups], + "layers" => json_body[:layers] + } +end diff --git a/app/controllers/wrappers/policy_audit.rb b/app/controllers/wrappers/policy_audit.rb new file mode 100644 index 0000000000..36ee2ea311 --- /dev/null +++ b/app/controllers/wrappers/policy_audit.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PolicyAudit + + def audit_success(policy) + policy.policy_log.lazy.map(&:to_audit_event).each do |event| + Audit.logger.log(event) + end + end + + def audit_failure(err, operation) + Audit.logger.log( + Audit::Event::Policy.new( + operation: operation, + subject: {}, # Subject is empty because no role/resource has been impacted + user: current_user, + client_ip: request.ip, + error_message: err.message + ) + ) + end +end \ No newline at end of file diff --git a/app/controllers/wrappers/policy_wrapper.rb b/app/controllers/wrappers/policy_wrapper.rb new file mode 100644 index 0000000000..90a4cb11a5 --- /dev/null +++ b/app/controllers/wrappers/policy_wrapper.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Policy wrapper to include stuff already existing in policies_controller.rb, +# to allow flexibility for rest endpoint who will use it differently +module PolicyWrapper + extend ActiveSupport::Concern + + def load_policy(loader_class, delete_permitted) + policy = save_submitted_policy(delete_permitted: delete_permitted) + loaded_policy = loader_class.from_policy(policy) + created_roles = perform(loaded_policy) + { created_roles: created_roles, policy: policy } + end + + def raw_policy + @raw_policy + end + + def set_raw_policy(raw_policy) + @raw_policy = raw_policy + end + + def save_submitted_policy(delete_permitted:) + policy_version = PolicyVersion.new( + role: current_user, + policy: resource, + policy_text: raw_policy, + client_ip: request.ip + ) + policy_version.delete_permitted = delete_permitted + policy_version.save + end + + def perform(policy_action) + policy_action.call + new_actor_roles = actor_roles(policy_action.new_roles) + create_roles(new_actor_roles) + end + + def actor_roles(roles) + roles.select do |role| + %w[user host].member?(role.kind) + end + end + + def create_roles(actor_roles) + actor_roles.each_with_object({}) do |role, memo| + credentials = Credentials[role: role] || Credentials.create(role: role) + role_id = role.id + memo[role_id] = { id: role_id, api_key: credentials.api_key } + end + end +end \ No newline at end of file diff --git a/app/controllers/wrappers/templates_renderer.rb b/app/controllers/wrappers/templates_renderer.rb new file mode 100644 index 0000000000..5001cef6e2 --- /dev/null +++ b/app/controllers/wrappers/templates_renderer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module PolicyTemplates + module TemplatesRenderer + def renderer(template, hash_input) + ERB.new(template.template, trim_mode: '-').result_with_hash(hash_input) + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/base_template.rb b/app/domain/policy-templates/base_template.rb new file mode 100644 index 0000000000..aae0c21754 --- /dev/null +++ b/app/domain/policy-templates/base_template.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module PolicyTemplates + class BaseTemplate + def template + raise NotImplementedError, "This method is not implemented because it's a base class" + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/hosts/create_host.rb b/app/domain/policy-templates/hosts/create_host.rb new file mode 100644 index 0000000000..74940905c8 --- /dev/null +++ b/app/domain/policy-templates/hosts/create_host.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateHost < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !host + id: <%= id %> + <% unless annotations.nil? || annotations.empty? %> + annotations: + <%- annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <%- end -%> + <% end %> + <% unless groups.nil? || groups.empty?%> + <% groups.each do |group| %> + - !grant + role: !group <%= group %> + member: !host <%= id %> + <% end %> + <% end %> + <% unless layers.nil? || layers.empty?%> + <% layers.each do |layer| %> + - !grant + role: !layer <%= layer %> + member: !host <%= id %> + <% end %> + <% end %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 9ce17994cf..eaa1a39783 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,8 @@ def matches?(request) get "/resources/:account" => "resources#index" get "/resources" => "resources#index" + post "hosts/:account/*identifier" => "hosts#post" + get "/:authenticator/:account/providers" => "providers#index" # NOTE: the order of these routes matters: we need the expire diff --git a/spec/app/domain/policy-templates/base_template_spec.rb b/spec/app/domain/policy-templates/base_template_spec.rb new file mode 100644 index 0000000000..21a5b24846 --- /dev/null +++ b/spec/app/domain/policy-templates/base_template_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe PolicyTemplates::BaseTemplate do + context "check throw error when called template" do + it "raises an error" do + expect { PolicyTemplates::BaseTemplate.new().template } + .to raise_error(NotImplementedError) + end + end +end \ No newline at end of file diff --git a/spec/controllers/concerns/find_policy_resource_spec.rb b/spec/controllers/concerns/find_policy_resource_spec.rb new file mode 100644 index 0000000000..1567d6026e --- /dev/null +++ b/spec/controllers/concerns/find_policy_resource_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe FindPolicyResource do + context "when resource cannot be found" do + let(:resource) { nil } + describe '#resource' do + it "raises an error" do + expect { controller.send(:resource) } + .to raise_error(Exceptions::RecordNotFound) + end + end + end + + before do + allow(Resource).to receive(:[]).with(resource_id).and_return(resource) + allow(controller).to receive(:resource_id) { resource_id } + end + + let(:resource_id) { 'test:policy:resource' } + + # Test controller class + class Controller + include FindPolicyResource + end + + subject(:controller) { Controller.new } +end diff --git a/spec/controllers/hosts_controller_spec.rb b/spec/controllers/hosts_controller_spec.rb new file mode 100644 index 0000000000..f5acb0f59c --- /dev/null +++ b/spec/controllers/hosts_controller_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'spec_helper' +DatabaseCleaner.strategy = :truncation + +describe HostsController, type: :request do + let(:test_value) { "testvalue" } + let(:url_variable) { "/secrets/rspec/variable" } + before do + init_slosilo_keys("rspec") + # Load the test policy into Conjur + + put( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge( + { 'RAW_POST_DATA' => test_policy } + ) + ) + assert_response :success + [ + "dev/secret1", + "dev/secret2", + "dev/secret3" + ].each do |path| + post("#{url_variable}/#{path}", + env: token_auth_header(role: admin_user).merge( + { 'RAW_POST_DATA' => "#{test_value}" } + ) + ) + assert_response :success + end + + end + + let(:test_policy) do + <<~POLICY + - !user alice + + - !policy + id: dev + body: + - !group developers + - !layer layers + - !variable secret1 + - !variable secret2 + - !variable secret3 + + - !grant + role: !group dev/developers + member: !user alice + + - !permit + resource: !policy dev + privilege: [ create ] + role: !user alice + + - !permit + resource: !variable dev/secret1 + privileges: [ read, execute ] + roles: !group dev/developers + + - !permit + resource: !variable dev/secret2 + privileges: [ read, execute ] + roles: !group dev/developers + + - !permit + resource: !variable dev/secret3 + privileges: [ read, execute ] + roles: !group dev/developers + POLICY + end + + let(:admin_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + let(:current_user) { Role.find_or_create(role_id: current_user_id) } + let(:current_user_id) { 'rspec:user:admin' } + let(:alice_user) { Role.find_or_create(role_id: alice_user_id) } + let(:alice_user_id) { 'rspec:user:alice' } + + describe "#post" do + context "when user send body with id only" do + let(:payload_create_hosts) do + <<~BODY + { "id": "new-host" } + BODY + end + it 'returns created' do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_create_hosts, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :created + end + end + + context "when user send body with annotations, groups and layers" do + let(:payload_create_hosts_annotations) do + <<~BODY + { + "id": "new-host2", + "annotations": { + "description": "describe" + }, + "groups": [ + "developers" + ], + "layers": [ + "layers" + ] + } + BODY + end + it 'returns created and can fetch secret' do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_create_hosts_annotations, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :created + host_role = Role.find(role_id: "rspec:host:dev/new-host2") + get("#{url_variable}/dev/secret1", + env: token_auth_header(role: host_role)) + expect(response.body).to include("#{test_value}") + end + it 'returns not found invalid path' do + post("/hosts/rspec/dev/invalid", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_create_hosts_annotations, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :not_found + end + end + + context "empty id or body" do + let(:payload_empty) do + <<~BODY + { + } + BODY + end + it 'empty returns unprocessable_entity' do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_empty, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :unprocessable_entity + end + let(:payload_blank_id) do + <<~BODY + { + "id": "" + } + BODY + end + it "blank id return unprocessable_entity" do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_blank_id, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :unprocessable_entity + end + end + + context "Invalid values for groups and layers" do + let(:payload_invalid_group) do + <<~BODY + { + "id": "new-host2", + "annotations": { + "description": "describe" + }, + "groups": [ + "invalid" + ] + } + BODY + end + it 'invalid group value should return 400' do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_invalid_group, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :not_found + end + let(:payload_invalid_layer) do + <<~BODY + { + "id": "new-host2", + "annotations": { + "description": "describe" + }, + "layers": [ + "invalid" + ] + } + BODY + end + it 'invalid layer value should return 400' do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_invalid_layer, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :not_found + end + end + end + +end diff --git a/spec/controllers/wrappers/template_renderer_spec.rb b/spec/controllers/wrappers/template_renderer_spec.rb new file mode 100644 index 0000000000..3cce58347c --- /dev/null +++ b/spec/controllers/wrappers/template_renderer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PolicyTemplates::TemplatesRenderer do + + subject(:controller) { Controller.new } + + context "Template works" do + + it "run" do + value = controller.send(:show) + expect(value).to include("test") + end + + end + + class Controller + include PolicyTemplates::TemplatesRenderer + + def show + renderer(template, input) + end + + def template + FakeTemplate.new + end + + def input + { + "id" => "test" + } + end + end + + class FakeTemplate < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + <%= id %> + TEMPLATE + end + end + +end \ No newline at end of file From a5f8642afc77c21ea7dfcad915fd5015a3337429 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 5 Jul 2023 09:26:28 +0300 Subject: [PATCH 052/665] modify slosilo endpoint to return current and prev keys --- CHANGELOG.md | 8 ++--- app/controllers/edge_controller.rb | 23 +++++++++---- app/models/account.rb | 8 ++--- cucumber/_common/slosilo_helper.rb | 7 ++-- cucumber/api/features/edge.feature | 4 +++ cucumber/api/features/support/env.rb | 2 ++ spec/controllers/edge_controller_spec.rb | 42 ++++++++++++++++++------ spec/support/slosilo_helper.rb | 8 ++--- 8 files changed, 70 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa550c7e45..2e59cfd3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.2-cloud] - 2023-07-16 -### Changed -- Add workload create endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41138 - -## [1.0.1-cloud] - 2023-06-21 +## [1.0.1-cloud] - 2023-07-09 ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 - Pull Slosilo library to Conjur - Change Slosilo id from "authn:account:host/user" to "authn:account:host/user:current" - Add update slosilo key option to slosilo put key function - Add slosilo key rotation scheduled task +- Add workload create endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41138 +- Modify slosilo endpoint to return current and previous keys ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 9966cc8e59..2bebb9ad78 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -18,14 +18,16 @@ def slosilo_keys if key.nil? raise RecordNotFound, "No Slosilo key in DB" end + return_json = {} + key_object = [get_key_object(key)] + return_json[:slosiloKeys] = key_object + + prev_key = Account.token_key(account, "host", "previous") + prev_key_obj = prev_key.nil? ? [] : [get_key_object(prev_key)] + return_json[:previousSlosiloKeys] = prev_key_obj - private_key = key.to_der.unpack("H*")[0] - fingerprint = key.fingerprint - variable_to_return = {} - variable_to_return[:privateKey] = private_key - variable_to_return[:fingerprint] = fingerprint logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("slosilo_keys")) - render(json: { "slosiloKeys": [variable_to_return] }) + render(json: return_json) end # Return all secrets within offset-limit frame. Default is 0-1000 @@ -166,6 +168,15 @@ def all_hosts private + def get_key_object(key) + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + variable_to_return = {} + variable_to_return[:privateKey] = private_key + variable_to_return[:fingerprint] = fingerprint + variable_to_return + end + def build_variables_map(limit, offset, options) variables = {} diff --git a/app/models/account.rb b/app/models/account.rb index ee5b29e652..49cc36b963 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -18,12 +18,12 @@ def find_or_create_accounts_resource INVALID_ID_CHARS = /[ :]/.freeze - def token_key(account, role) - Slosilo[token_id(account, role)] + def token_key(account, role, tag = "current") + Slosilo[token_id(account, role, tag)] end - def token_id(account, role) - "authn:#{account}:#{role}:current" + def token_id(account, role, tag = "current") + "authn:#{account}:#{role}:#{tag}" end def create(id, owner_id = nil) diff --git a/cucumber/_common/slosilo_helper.rb b/cucumber/_common/slosilo_helper.rb index 9e37c47de0..b61be624e0 100644 --- a/cucumber/_common/slosilo_helper.rb +++ b/cucumber/_common/slosilo_helper.rb @@ -1,10 +1,11 @@ -def token_id(account, role) - "authn:#{account}:#{role}:current" +def token_id(account, role, tag = "current") + "authn:#{account}:#{role}:#{tag}" end def init_slosilo_keys - slosilo_ids = [token_id("rspec", "host"), token_id("rspec", "user"), token_id("cucumber", "host"), token_id("cucumber", "user")] + slosilo_ids = [token_id("rspec", "host"), token_id("rspec", "user"), token_id("cucumber", "host"), token_id("cucumber", "user"), + token_id("cucumber", "user", "previous"),token_id("cucumber", "host", "previous") ] Slosilo.each do |k, v| unless slosilo_ids.member?(k) Slosilo.send(:keystore).adapter.model[k].delete diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index ac24028d4b..92e526a3f9 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -77,6 +77,10 @@ Feature: Fetching secrets from edge endpoint And the JSON at "slosiloKeys/0/fingerprint" should be a string And the JSON should have "slosiloKeys/0/privateKey" And the JSON at "slosiloKeys/0/privateKey" should be a string + And the JSON at "previousSlosiloKeys" should have 1 entries + And the JSON at "previousSlosiloKeys/0/fingerprint" should be a string + And the JSON should have "previousSlosiloKeys/0/privateKey" + And the JSON at "previousSlosiloKeys/0/privateKey" should be a string @negative @acceptance Scenario: Fetching hosts with non edge host return 403 diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index f81b2a694a..5ee5706374 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -19,5 +19,7 @@ Slosilo["authn:cucumber:user:current"] ||= Slosilo::Key.new Slosilo["authn:cucumber:host:current"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:user:previous"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:host:previous"] ||= Slosilo::Key.new JsonSpec.excluded_keys = %w[created_at updated_at] diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 4a922eaece..ccb35092fd 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -28,24 +28,46 @@ { 'HTTP_AUTHORIZATION' => token_auth_str } end + let(:init_prev_key) do + Slosilo[token_id(account, "host", "previous")] ||= Slosilo::Key.new + end + + def send_request_with_correct_role + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("200") + end + context "slosilo keys in DB" do - it "Slosilo keys equals to key in DB, Host and Role are correct" do - #add edge-hosts to edge/edge-hosts group - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + it "Host and Role are correct, previous key is empty" do + send_request_with_correct_role + #get the Slosilo key from DB + key = token_key(account, "host") + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint - #get the Slosilo key the URL request - get(update_slosilo_keys_url, env: token_auth_header) - expect(response.code).to eq("200") + expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}], "previousSlosiloKeys" => []} + response_json = JSON.parse(response.body) + expect(response_json).to eq(expected) + end + it "Host and Role are correct, previous key exist in db" do + init_prev_key + send_request_with_correct_role #get the Slosilo key from DB key = token_key(account, "host") private_key = key.to_der.unpack("H*")[0] fingerprint = key.fingerprint + #get prev Slosilo key from DB + prev_key = token_key(account, "host", "previous") + prev_private_key = prev_key.to_der.unpack("H*")[0] + prev_fingerprint = prev_key.fingerprint - expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}]} + expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}], "previousSlosiloKeys" => [{"privateKey"=> prev_private_key,"fingerprint"=>prev_fingerprint}]} response_json = JSON.parse(response.body) - expect(response_json).to include(expected) + expect(response_json).to eq(expected) end it "Host is Edge but no Role exists at all" do @@ -83,4 +105,4 @@ expect(test_api_key).to eq(encoded_api_key) end end -end \ No newline at end of file +end diff --git a/spec/support/slosilo_helper.rb b/spec/support/slosilo_helper.rb index d1f91e6ada..1b78914ef8 100644 --- a/spec/support/slosilo_helper.rb +++ b/spec/support/slosilo_helper.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -def token_key(account, role) - Slosilo[token_id(account, role)] +def token_key(account, role, tag = "current") + Slosilo[token_id(account, role, tag)] end -def token_id(account, role) - "authn:#{account}:#{role}:current" +def token_id(account, role, tag = "current") + "authn:#{account}:#{role}:#{tag}" end def init_slosilo_keys(account) From 1509e4ebca3e7389789a8960dc7bba301b71392b Mon Sep 17 00:00:00 2001 From: egvili Date: Tue, 13 Jun 2023 16:11:12 +0300 Subject: [PATCH 053/665] Multi Edge table --- CHANGELOG.md | 3 +- app/controllers/concerns/account_validator.rb | 15 +++ app/controllers/concerns/edge_validator.rb | 49 ++++++++ .../concerns/group_membership_validator.rb | 29 +++++ app/controllers/edge_controller.rb | 76 ++++++------- app/db/preview/single_edge_to_multi.rb | 19 ++++ app/models/edge.rb | 41 +++++++ config/routes.rb | 2 + cucumber/api/features/edge.feature | 14 +++ .../20230618140702_create_edges_table.rb | 27 +++++ spec/controllers/edge_controller_spec.rb | 105 ++++++++++++++++-- 11 files changed, 325 insertions(+), 55 deletions(-) create mode 100644 app/controllers/concerns/account_validator.rb create mode 100644 app/controllers/concerns/edge_validator.rb create mode 100644 app/controllers/concerns/group_membership_validator.rb create mode 100644 app/db/preview/single_edge_to_multi.rb create mode 100644 app/models/edge.rb create mode 100644 db/migrate/20230618140702_create_edges_table.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e59cfd3de..150a3c8956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.1-cloud] - 2023-07-09 +## [1.0.1-cloud] - 2023-07-16 ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 - Pull Slosilo library to Conjur @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Add slosilo key rotation scheduled task - Add workload create endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41138 - Modify slosilo endpoint to return current and previous keys +- Add Edges table and allow updating it by Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-40661 ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/app/controllers/concerns/account_validator.rb b/app/controllers/concerns/account_validator.rb new file mode 100644 index 0000000000..3cf580517b --- /dev/null +++ b/app/controllers/concerns/account_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module AccountValidator + extend ActiveSupport::Concern + def validate_account(account) + if %w[conjur cucumber rspec].exclude?(account) + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Account is: #{account}. Should be one of the following: [conjur cucumber rspec]" + ) + ) + raise ApplicationController::Forbidden + end + end +end diff --git a/app/controllers/concerns/edge_validator.rb b/app/controllers/concerns/edge_validator.rb new file mode 100644 index 0000000000..feb5994ef9 --- /dev/null +++ b/app/controllers/concerns/edge_validator.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module EdgeValidator + extend ActiveSupport::Concern + + def verify_edge_host(options) + msg = "" + raise_excep = false + + validate_account(options[:account]) + + if current_user.kind != 'host' + raise_excep = true + msg = "User kind is: #{current_user.kind}. Should be: 'host'" + elsif current_user.role_id.exclude?("host:edge/edge") + raise_excep = true + msg = "Role is: #{current_user.role_id}. Should include: 'host:edge/edge'" + else + role = Role[options[:account] + ':group:edge/edge-hosts'] + unless role&.ancestor_of?(current_user) + raise_excep = true + msg = "Curren user is: #{current_user}. should be member of #{role}" + end + end + + if raise_excep + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + msg + ) + ) + raise ApplicationController::Forbidden + end + end + + def validate_scope(limit, offset) + if offset || limit + # 'limit' must be an integer greater than 0 and less than 2000 if given + if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000) + raise ArgumentError, "'limit' contains an invalid value. 'limit' must be a positive integer and less than 2000" + end + # 'offset' must be an integer greater than or equal to 0 if given + if offset && (!numeric?(offset) || offset.to_i.negative?) + raise ArgumentError, "'offset' contains an invalid value. 'offset' must be an integer greater than or equal to 0." + end + end + end + +end diff --git a/app/controllers/concerns/group_membership_validator.rb b/app/controllers/concerns/group_membership_validator.rb new file mode 100644 index 0000000000..bb91d9b644 --- /dev/null +++ b/app/controllers/concerns/group_membership_validator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module GroupMembershipValidator + extend ActiveSupport::Concern + + def validate_conjur_admin_group(account) + validate_account(account) + + unless is_role_member_of_group(account, current_user.id, ':group:Conjur_Cloud_Admins') + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Current user role is: #{current_user.id}. should be member of: \"group:Conjur_Cloud_Admins\"" + ) + ) + raise ApplicationController::Forbidden + end + end + + private + + def is_role_member_of_group(account, role_id, group_name) + role = Role[account + group_name] + unless role&.ancestor_of?(role = Role[role_id]) + return false + end + return true + end + +end diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 2bebb9ad78..8a970910d2 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -2,6 +2,10 @@ class EdgeController < RestController include Cryptography + include EdgeValidator + include AccountValidator + include GroupMembershipValidator + include BodyParser def slosilo_keys logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) @@ -166,6 +170,33 @@ def all_hosts end end + def all_edges + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/edges")) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + validate_conjur_admin_group(options[:account]) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edges")) + render(json: Edge.all.map{|edge| + {name: edge.name, ip: edge.ip, last_sync: edge.last_sync.to_i, + version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) + end + + def report_edge_data + logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/data')) + params.require(:edge_statistics).require(:last_synch_time) + allowed_params = [:account, :edge_version, :edge_container_type, edge_statistics: [:last_synch_time, cycle_requests: + [:get_secret, :apikey_authenticate, :jwt_authenticate, :redirect]]] + options = params.permit(*allowed_params).to_h + verify_edge_host(options) + begin + Edge.record_edge_access(current_user.role_id, options, request.ip) + rescue Exceptions::RecordNotFound + raise RecordNotFound.new(host_name, message: "Edge for host #{current_user.role_id} not found") + end + #TODO: print to log other data + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/data")) + end + private def get_key_object(key) @@ -192,51 +223,8 @@ def build_variables_map(limit, offset, options) variables end - def validate_scope(limit, offset) - if offset || limit - # 'limit' must be an integer greater than 0 and less than 2000 if given - if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000) - raise ArgumentError, "'limit' contains an invalid value. 'limit' must be a positive integer and less than 2000" - end - # 'offset' must be an integer greater than or equal to 0 if given - if offset && (!numeric?(offset) || offset.to_i.negative?) - raise ArgumentError, "'offset' contains an invalid value. 'offset' must be an integer greater than or equal to 0." - end - end - end - - def verify_edge_host(options) - msg = "" - raise_excep = false - - if %w[conjur cucumber rspec].exclude?(options[:account]) - raise_excep = true - msg = "Account is: #{options[:account]}. Should be one of the following: [conjur cucumber rspec]" - elsif current_user.kind != 'host' - raise_excep = true - msg = "User kind is: #{current_user.kind}. Should be: 'host'" - elsif current_user.role_id.exclude?("host:edge/edge") - raise_excep = true - msg = "Role is: #{current_user.role_id}. Should include: 'host:edge/edge'" - else - role = Role[options[:account] + ':group:edge/edge-hosts'] - unless role&.ancestor_of?(current_user) - raise_excep = true - msg = "Curren user is: #{current_user}. should be member of #{role}" - end - end - - if raise_excep - logger.error( - Errors::Authorization::EndpointNotVisibleToRole.new( - msg - ) - ) - raise Forbidden - end - end - def numeric? val val == val.to_i.to_s end + end diff --git a/app/db/preview/single_edge_to_multi.rb b/app/db/preview/single_edge_to_multi.rb new file mode 100644 index 0000000000..03ecb0d1fc --- /dev/null +++ b/app/db/preview/single_edge_to_multi.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module DB + module Preview + class SingleEdgeToMulti + def find_single_host_id + edge_host = Role.where(:role_id.like('conjur:host:edge/%edge-host-%')).first + edge_installer = Role.where(:role_id.like('conjur:host:edge/%edge-installer-host-%')).first + return get_edge_id(edge_host.role_id) if edge_host && edge_installer + nil + end + + def get_edge_id(hostname) + regex = /(?<=#{'edge-host-'})(.+)/ + hostname.match(regex)&.captures&.first + end + end + end +end \ No newline at end of file diff --git a/app/models/edge.rb b/app/models/edge.rb new file mode 100644 index 0000000000..728489f3ec --- /dev/null +++ b/app/models/edge.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'securerandom' +require 'sequel' + +class Edge < Sequel::Model + + class << self + + EDGE_HOST_PREFIX = 'edge-host-' + + def new_edge(**values) + values[:id] ||= SecureRandom.uuid + Edge.insert(**values) + end + + def record_edge_access(host_name, data, ip) + edge_record = Edge.get_by_hostname(host_name) || raise(Exceptions::RecordNotFound) + edge_record.ip = ip + edge_record.version = data['edge_version'] if data['edge_version'] + sync_time = Time.at(data['edge_statistics']['last_synch_time']) # This field is required + edge_record.last_sync = sync_time + edge_record.installation_date = sync_time unless edge_record.installation_date + edge_record.platform = data['edge_container_type'] if data['edge_container_type'] + + edge_record.save + end + + def get_by_hostname(hostname) + Edge.where(id: hostname_to_id(hostname)).first + end + + def hostname_to_id(hostname) + regex = /(?<=#{EDGE_HOST_PREFIX})(.+)/ + hostname.match(regex)&.captures&.first + end + + def id_to_hostname(id) + EDGE_HOST_PREFIX + id + end + end +end diff --git a/config/routes.rb b/config/routes.rb index eaa1a39783..d6bad5a206 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,6 +80,8 @@ def matches?(request) get "/secrets" => 'secrets#batch' get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' + get "/edge/edges/:account" => 'edge#all_edges' + post "/edge/data/:account" => 'edge#report_edge_data' get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' put "/policies/:account/:kind/*identifier" => 'policies#put' diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 92e526a3f9..6124bc9687 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -3,6 +3,7 @@ Feature: Fetching secrets from edge endpoint Background: Given I create a new user "some_user" + And I create a new user "admin_user" And I have host "data/some_host1" And I have host "data/some_host2" And I have host "data/some_host3" @@ -56,6 +57,10 @@ Feature: Fetching secrets from edge endpoint role: !host some_host4 privilege: [ write ] resource: !variable secret1 + - !group Conjur_Cloud_Admins + - !grant + role: !group Conjur_Cloud_Admins + member: !user admin_user """ And I add the secret value "s1" to the resource "cucumber:variable:data/secret1" And I add the secret value "s2" to the resource "cucumber:variable:data/secret2" @@ -657,3 +662,12 @@ Feature: Fetching secrets from edge endpoint Given I am the super-user When I GET "/edge/hosts/cucumber" Then the HTTP response status code is 403 + + @negative @acceptance + Scenario: List edges permitted only to admins + Given I login as "admin_user" + When I GET "/edge/edges/cucumber" + Then the HTTP response status code is 200 + Given I login as "some_user" + When I GET "/edge/edges/cucumber" + Then the HTTP response status code is 403 diff --git a/db/migrate/20230618140702_create_edges_table.rb b/db/migrate/20230618140702_create_edges_table.rb new file mode 100644 index 0000000000..b15687498f --- /dev/null +++ b/db/migrate/20230618140702_create_edges_table.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +Dir[File.dirname(__FILE__) + '/../../app/db/preview/*.rb'].each do |file| + require file +end + +Sequel.migration do + change do + def migrate_single_edge + single_host_id = ::DB::Preview::SingleEdgeToMulti.new.find_single_host_id + if single_host_id + self[:edges].insert(name: "first_edge", id: single_host_id, version: "1.0.2") + end + end + + create_table :edges do + String :id, primary_key: true + String :name, unique: true, null: false + String :ip, null: true + String :version, null: true + String :platform, null: true + Timestamp :last_sync, null: true + Timestamp :installation_date, null: true + end + + migrate_single_edge + end +end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index ccb35092fd..e65f63db65 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -4,13 +4,15 @@ describe EdgeController, :type => :request do let(:account) { "rspec" } - let(:host_id) {"#{account}:host:edge/edge"} + let(:host_id) {"#{account}:host:edge/edge-host-1234"} let(:other_host_id) {"#{account}:host:data/other"} + let(:admin_user_id) {"#{account}:user:admin_user"} before do init_slosilo_keys(account) @current_user = Role.find_or_create(role_id: host_id) @other_user = Role.find_or_create(role_id: other_host_id) + @admin_user = Role.find_or_create(role_id: admin_user_id) end let(:update_slosilo_keys_url) do @@ -21,13 +23,15 @@ "/edge/hosts/#{account}" end - let(:token_auth_header) do - bearer_token = token_key(account, "host").signed_token(@current_user.login) - token_auth_str = - "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" - { 'HTTP_AUTHORIZATION' => token_auth_str } + let(:list_edges) do + "/edge/edges/#{account}" end + let(:report_edge) do + "/edge/data/#{account}" + end + + let(:init_prev_key) do Slosilo[token_id(account, "host", "previous")] ||= Slosilo::Key.new end @@ -36,7 +40,7 @@ def send_request_with_correct_role #add edge-hosts to edge/edge-hosts group Role.create(role_id: "#{account}:group:edge/edge-hosts") RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - get(update_slosilo_keys_url, env: token_auth_header) + get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) expect(response.code).to eq("200") end @@ -72,7 +76,7 @@ def send_request_with_correct_role it "Host is Edge but no Role exists at all" do #get the Slosilo key the URL request - get(update_slosilo_keys_url, env: token_auth_header) + get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) expect(response.code).to eq("403") end @@ -82,7 +86,7 @@ def send_request_with_correct_role RoleMembership.create(role_id: "#{account}:group:edge2/edge-hosts", member_id: host_id, admin_option: false, ownership:false) #get the Slosilo key the URL request - get(update_slosilo_keys_url, env: token_auth_header) + get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) expect(response.code).to eq("403") end end @@ -92,7 +96,7 @@ def send_request_with_correct_role #add edge-hosts to edge/edge-hosts group Role.create(role_id: "#{account}:group:edge/edge-hosts") RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - get(get_hosts, env: token_auth_header) + get(get_hosts, env: token_auth_header(role: @current_user, is_user: false)) expect(response.code).to eq("200") expect(response).to be_ok expect(response.body).to include("api_key".strip) @@ -105,4 +109,85 @@ def send_request_with_correct_role expect(test_api_key).to eq(encoded_api_key) end end + + context "Visibility" do + it "Report data endpoint works" do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + Edge.new_edge(name: "edgy", id: 1234) + + + edge_details = '{"edge_statistics": {"last_synch_time": 123456789}, "edge_version": "latest", "edge_container_type": "podman"}' + post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + + expect(response.code).to eq("204") + db_edgy = Edge.where(name: "edgy").first + expect(db_edgy.last_sync.to_i).to eq(123456789) + expect(db_edgy.version).to eq("latest") + expect(db_edgy.platform).to eq("podman") + end + + it "List endpoint works" do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + sync_time = Time.now + Edge.new_edge(name: "edgy", id: 1234, last_sync: sync_time, version: "latest", platform: "podman") + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + + get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) + + expect(response.code).to eq("200") + resp = JSON.parse(response.body) + expect(resp.size).to eq(1) + expect(resp[0]['last_sync']).to eq(sync_time.to_i) + expect(resp[0]['version']).to eq("latest") + expect(resp[0]['platform']).to eq("podman") + end + + it "Reported data appears on list" do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + Edge.new_edge(name: "edgy", id: 1234) + + edge_details = '{"edge_statistics": {"last_synch_time": 123456789}, "edge_version": "latest", "edge_container_type": "podman"}' + post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) + expect(response.code).to eq("200") + resp = JSON.parse(response.body) + expect(resp.size).to eq(1) + expect(resp[0]['last_sync']).to eq(123456789) + expect(resp[0]['version']).to eq("latest") + expect(resp[0]['platform']).to eq("podman") + end + + it "Report invalid data" do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + Edge.new_edge(name: "edgy", id: 1234) + + missing_optional = '{"edge_statistics": {"last_synch_time": 123456789}, "edge_version": "latest"}' + post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': missing_optional}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + + missing_required = '{"edge_statistics": {}, "edge_version": "latest", "edge_container_type": "podman"}' + post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': missing_required}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("422") + end + end end From f292661d60bd244a10faf03131438d7013702c53 Mon Sep 17 00:00:00 2001 From: egvili Date: Tue, 13 Jun 2023 16:11:12 +0300 Subject: [PATCH 054/665] Fix Edge IP hiding user's actions --- CHANGELOG.md | 1 + lib/conjur/is_ip_trusted.rb | 21 +++++++++++++++++---- spec/lib/conjur/is_ip_trusted_spec.rb | 25 +++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150a3c8956..6aee266d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Add workload create endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41138 - Modify slosilo endpoint to return current and previous keys - Add Edges table and allow updating it by Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-40661 +- Mask Edge IP from audits emitted by Edge forwards https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-38139 ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/lib/conjur/is_ip_trusted.rb b/lib/conjur/is_ip_trusted.rb index a95428774e..47bd2ac500 100644 --- a/lib/conjur/is_ip_trusted.rb +++ b/lib/conjur/is_ip_trusted.rb @@ -14,6 +14,7 @@ def initialize(config:, disable_cache: true) @config = config @disable_cache = disable_cache @cached_trusted_proxies = nil + @cache_expiration = Time.at(0) # Validate the values in TRUSTED_PROXIES at creation validate_trusted_proxies @@ -30,18 +31,22 @@ def validate_trusted_proxies end def trusted_proxies - return @cached_trusted_proxies if @cached_trusted_proxies + return @cached_trusted_proxies if @cached_trusted_proxies and @cache_expiration >= Time.now # The trusted proxy IPs are `127.0.0.1` plus those defined in the # `TRUSTED_PROXIES` environment variable. - proxy_ips = [IPAddr.new('127.0.0.1')] + configured_trusted_proxies + proxy_ips = [IPAddr.new('127.0.0.1')] + configured_trusted_proxies + detected_trusted_proxies # If not disabled, cache the IP address list - @cached_trusted_proxies = proxy_ips unless @disable_cache - + unless @disable_cache + @cached_trusted_proxies = proxy_ips + @cache_expiration = Time.now + 60 + end proxy_ips end + private + def configured_trusted_proxies trusted_proxies = @config.trusted_proxies @@ -50,5 +55,13 @@ def configured_trusted_proxies Set.new(trusted_proxies) .map { |cidr| IPAddr.new(cidr.strip) } end + + def detected_trusted_proxies + begin + return Edge.all.map {|edge| IPAddr.new(edge.ip.strip)} + rescue + return [] + end + end end end diff --git a/spec/lib/conjur/is_ip_trusted_spec.rb b/spec/lib/conjur/is_ip_trusted_spec.rb index 92de700091..bb40d19116 100644 --- a/spec/lib/conjur/is_ip_trusted_spec.rb +++ b/spec/lib/conjur/is_ip_trusted_spec.rb @@ -3,11 +3,32 @@ require 'spec_helper' describe Conjur::IsIpTrusted do - it "does not raise an exception when created with valid IP addresses" do - config = Conjur::ConjurConfig.new(trusted_proxies: '127.0.0.1') + let(:config) { Conjur::ConjurConfig.new(trusted_proxies: '127.0.0.1') } + + it "does not raise an exception when created with valid IP addresses" do expect { Conjur::IsIpTrusted.new(config: config) }.not_to raise_error end + + it "Configuration IPs are considered as trusted IPS" do + is_trusted = Conjur::IsIpTrusted.new(config: config).call("127.0.0.1") + expect(is_trusted).to eq(true) + end + + it "Edge IPs are considered as trusted IPS" do + Edge.new_edge(name: "edgy", ip: "1.1.1.1") + + is_trusted = Conjur::IsIpTrusted.new(config: config).call("1.1.1.1") + expect(is_trusted).to eq(true) + end + + it "DB is not queried too often" do + is_ip_trusted = Conjur::IsIpTrusted.new(config: config, disable_cache: false) + Edge.new_edge(name: "edgy", ip: "1.1.1.1") + is_trusted = is_ip_trusted.call("1.1.1.1") + # Expecting false since it indicates that cache is used and not actual DB + expect(is_trusted).to eq(false) + end end From a6c9501a38905f6c3c6802982cd0dbd2875b85b7 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 10 Jul 2023 10:22:51 +0300 Subject: [PATCH 055/665] ignore rufus scheduler lock file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0708e2dc07..d6e93d3eed 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,7 @@ gem/slosilo/.project gem/slosilo/.kateproject.d gem/slosilo/.idea +# Rufus scheduler lock file +.slosilo-rotation-rufus-scheduler.lock + VERSION From 9b5ebe973b8cd18522953922d76698e15e8ad05e Mon Sep 17 00:00:00 2001 From: egvili Date: Thu, 13 Jul 2023 17:14:26 +0300 Subject: [PATCH 056/665] Revert single edge migration --- db/migrate/20230618140702_create_edges_table.rb | 9 --------- 1 file changed, 9 deletions(-) diff --git a/db/migrate/20230618140702_create_edges_table.rb b/db/migrate/20230618140702_create_edges_table.rb index b15687498f..55af392aa8 100644 --- a/db/migrate/20230618140702_create_edges_table.rb +++ b/db/migrate/20230618140702_create_edges_table.rb @@ -5,13 +5,6 @@ Sequel.migration do change do - def migrate_single_edge - single_host_id = ::DB::Preview::SingleEdgeToMulti.new.find_single_host_id - if single_host_id - self[:edges].insert(name: "first_edge", id: single_host_id, version: "1.0.2") - end - end - create_table :edges do String :id, primary_key: true String :name, unique: true, null: false @@ -21,7 +14,5 @@ def migrate_single_edge Timestamp :last_sync, null: true Timestamp :installation_date, null: true end - - migrate_single_edge end end From a72448238ce984fcfc96dcdc915a482ad0d4fac7 Mon Sep 17 00:00:00 2001 From: egvili Date: Tue, 11 Jul 2023 12:06:34 +0300 Subject: [PATCH 057/665] Extend data endpoint --- app/controllers/edge_controller.rb | 14 ++-- app/domain/logs.rb | 9 +++ app/models/edge.rb | 24 ++++--- .../data_handlers/install_handler.rb | 19 ++++++ .../data_handlers/ongoing_handler.rb | 28 ++++++++ config/routes.rb | 2 +- spec/controllers/edge_controller_spec.rb | 65 ++++++++++--------- 7 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 app/models/edge_logic/data_handlers/install_handler.rb create mode 100644 app/models/edge_logic/data_handlers/ongoing_handler.rb diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 8a970910d2..1c8960707b 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -183,17 +183,17 @@ def all_edges def report_edge_data logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/data')) - params.require(:edge_statistics).require(:last_synch_time) - allowed_params = [:account, :edge_version, :edge_container_type, edge_statistics: [:last_synch_time, cycle_requests: - [:get_secret, :apikey_authenticate, :jwt_authenticate, :redirect]]] - options = params.permit(*allowed_params).to_h - verify_edge_host(options) + allowed_params = %i[account data_type] + url_params = params.permit(*allowed_params) + verify_edge_host(url_params) + data_handlers = {'install' => EdgeLogic::DataHandlers::InstallHandler , 'ongoing' => EdgeLogic::DataHandlers::OngoingHandler} + handler = data_handlers[url_params[:data_type]] + raise BadRequest unless handler begin - Edge.record_edge_access(current_user.role_id, options, request.ip) + handler.new(logger).call(params, current_user.role_id, request.ip) rescue Exceptions::RecordNotFound raise RecordNotFound.new(host_name, message: "Edge for host #{current_user.role_id} not found") end - #TODO: print to log other data logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/data")) end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index c06039663f..fd32bb6f30 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -47,6 +47,15 @@ module Endpoints ) end + module Edge + EdgeTelemetry = ::Util::TrackableLogMessageClass.new( + msg: "Edge {0} was last synced at {1}. Requests served during last sync interval: " \ + "get secret: {2}, auth apikey: {3}, auth jwt: {4}, redirect {5}." \ + "Edge Info: version: {6}, platform: {7}, install time: {8}", + code: "CONJ00158" + ) + end + module Authentication LoginError = ::Util::TrackableErrorClass.new( diff --git a/app/models/edge.rb b/app/models/edge.rb index 728489f3ec..bafd463a99 100644 --- a/app/models/edge.rb +++ b/app/models/edge.rb @@ -13,20 +13,8 @@ def new_edge(**values) Edge.insert(**values) end - def record_edge_access(host_name, data, ip) - edge_record = Edge.get_by_hostname(host_name) || raise(Exceptions::RecordNotFound) - edge_record.ip = ip - edge_record.version = data['edge_version'] if data['edge_version'] - sync_time = Time.at(data['edge_statistics']['last_synch_time']) # This field is required - edge_record.last_sync = sync_time - edge_record.installation_date = sync_time unless edge_record.installation_date - edge_record.platform = data['edge_container_type'] if data['edge_container_type'] - - edge_record.save - end - def get_by_hostname(hostname) - Edge.where(id: hostname_to_id(hostname)).first + Edge.where(id: hostname_to_id(hostname)).first || raise(Exceptions::RecordNotFound) end def hostname_to_id(hostname) @@ -38,4 +26,14 @@ def id_to_hostname(id) EDGE_HOST_PREFIX + id end end + + def record_edge_access(data, ip) + self.ip = ip + self.version = data['edge_version'] if data['edge_version'] + sync_time = Time.at(data['edge_statistics']['last_synch_time']) # This field is required + self.last_sync = sync_time + self.platform = data['edge_container_type'] if data['edge_container_type'] + + self.save + end end diff --git a/app/models/edge_logic/data_handlers/install_handler.rb b/app/models/edge_logic/data_handlers/install_handler.rb new file mode 100644 index 0000000000..a0b771bdd6 --- /dev/null +++ b/app/models/edge_logic/data_handlers/install_handler.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module EdgeLogic + module DataHandlers + class InstallHandler + def initialize(logger) + @logger = logger + end + + def call(params, hostname, ip) + installation_date = params.require(:installation_date) + edge = Edge.get_by_hostname(hostname) + edge.update(installation_date: Time.at(installation_date)) + + #TODO write audit + end + end + end +end diff --git a/app/models/edge_logic/data_handlers/ongoing_handler.rb b/app/models/edge_logic/data_handlers/ongoing_handler.rb new file mode 100644 index 0000000000..b236aae111 --- /dev/null +++ b/app/models/edge_logic/data_handlers/ongoing_handler.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module EdgeLogic + module DataHandlers + class OngoingHandler + def initialize(logger) + @logger = logger + end + + def call(params, hostname, ip) + params.require(:edge_statistics).require(:last_synch_time) + allowed_params = [:account, :edge_version, :edge_container_type, edge_statistics: [:last_synch_time, cycle_requests: + [:get_secret, :apikey_authenticate, :jwt_authenticate, :redirect]]] + options = params.permit(*allowed_params).to_h + + edge = Edge.get_by_hostname(hostname) + edge.record_edge_access(options, ip) + # Log Edge statistics to be collected by Datadog + stats = options['edge_statistics'] + cycle_reqs = stats['cycle_requests'] || {} + @logger.info(LogMessages::Edge::EdgeTelemetry.new(edge.name, Time.at(stats['last_synch_time']), + cycle_reqs['get_secret'], cycle_reqs['apikey_authenticate'], + cycle_reqs['jwt_authenticate'], cycle_reqs['redirect'], + edge.version, edge.platform, Time.at(edge.installation_date))) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index d6bad5a206..863e0bcfee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,7 +81,7 @@ def matches?(request) get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' get "/edge/edges/:account" => 'edge#all_edges' - post "/edge/data/:account" => 'edge#report_edge_data' + post "/edge/data/:account" => 'edge#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' put "/policies/:account/:kind/*identifier" => 'policies#put' diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index e65f63db65..644e68ea31 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -8,6 +8,9 @@ let(:other_host_id) {"#{account}:host:data/other"} let(:admin_user_id) {"#{account}:user:admin_user"} + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + before do init_slosilo_keys(account) @current_user = Role.find_or_create(role_id: host_id) @@ -111,80 +114,80 @@ def send_request_with_correct_role end context "Visibility" do - it "Report data endpoint works" do + before do Role.create(role_id: "#{account}:group:edge/edge-hosts") RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + Edge.new_edge(name: "edgy", id: 1234, version: "latest", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + EdgeController.logger = logger + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + end - Edge.new_edge(name: "edgy", id: 1234) + it "Report install data endpoint works" do + edge_details = '{"installation_date": 111111111}' + post("#{report_edge}?data_type=install", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + db_edgy = Edge.where(name: "edgy").first + expect(db_edgy.installation_date.to_i).to eq(111111111) + end - edge_details = '{"edge_statistics": {"last_synch_time": 123456789}, "edge_version": "latest", "edge_container_type": "podman"}' - post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + it "Report ongoing data endpoint works" do + edge_details = '{"edge_statistics": {"last_synch_time": 222222222, "cycle_requests": { + "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, + "edge_version": "latest", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': edge_details}) .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("204") db_edgy = Edge.where(name: "edgy").first - expect(db_edgy.last_sync.to_i).to eq(123456789) + expect(db_edgy.last_sync.to_i).to eq(222222222) expect(db_edgy.version).to eq("latest") expect(db_edgy.platform).to eq("podman") + output = log_output.string + expect(output).to include("EdgeTelemetry") + %w[edgy 123 234 345 456].each {|arg| expect(output).to include(arg)} end it "List endpoint works" do - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - sync_time = Time.now - Edge.new_edge(name: "edgy", id: 1234, last_sync: sync_time, version: "latest", platform: "podman") - Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") - RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) - get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) expect(response.code).to eq("200") resp = JSON.parse(response.body) expect(resp.size).to eq(1) - expect(resp[0]['last_sync']).to eq(sync_time.to_i) + expect(resp[0]['last_sync']).to eq(222222222) expect(resp[0]['version']).to eq("latest") expect(resp[0]['platform']).to eq("podman") end it "Reported data appears on list" do - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - - Edge.new_edge(name: "edgy", id: 1234) - - edge_details = '{"edge_statistics": {"last_synch_time": 123456789}, "edge_version": "latest", "edge_container_type": "podman"}' - post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "latest", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': edge_details}) .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("204") - Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") - RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) expect(response.code).to eq("200") resp = JSON.parse(response.body) expect(resp.size).to eq(1) - expect(resp[0]['last_sync']).to eq(123456789) + expect(resp[0]['last_sync']).to eq(222222222) expect(resp[0]['version']).to eq("latest") expect(resp[0]['platform']).to eq("podman") end it "Report invalid data" do - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - - Edge.new_edge(name: "edgy", id: 1234) - - missing_optional = '{"edge_statistics": {"last_synch_time": 123456789}, "edge_version": "latest"}' - post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "latest"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': missing_optional}) .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("204") missing_required = '{"edge_statistics": {}, "edge_version": "latest", "edge_container_type": "podman"}' - post(report_edge, env: token_auth_header(role: @current_user, is_user: false) + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': missing_required}) .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("422") From 5a05f54d424b4933baa7226987466ebb0e462922 Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 16 Jul 2023 09:11:54 +0300 Subject: [PATCH 058/665] Update sync time only when needed --- app/models/edge.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/edge.rb b/app/models/edge.rb index bafd463a99..8a0b01bb60 100644 --- a/app/models/edge.rb +++ b/app/models/edge.rb @@ -31,7 +31,7 @@ def record_edge_access(data, ip) self.ip = ip self.version = data['edge_version'] if data['edge_version'] sync_time = Time.at(data['edge_statistics']['last_synch_time']) # This field is required - self.last_sync = sync_time + self.last_sync = sync_time if sync_time.to_i > 0 self.platform = data['edge_container_type'] if data['edge_container_type'] self.save From 76faa9d042cefd63780f756f032856e8c698d4cf Mon Sep 17 00:00:00 2001 From: nofarvered Date: Mon, 17 Jul 2023 14:31:54 +0300 Subject: [PATCH 059/665] Add exclude functionality to list-resources --- CHANGELOG.md | 1 + app/controllers/resources_controller.rb | 2 +- app/models/resource.rb | 11 ++- cucumber/api/features/resource_list.feature | 71 ++++++++++++++++++- .../step_definitions/response_steps.rb | 9 +++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aee266d37..d1c9e00cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Modify slosilo endpoint to return current and previous keys - Add Edges table and allow updating it by Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-40661 - Mask Edge IP from audits emitted by Edge forwards https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-38139 +- Add an exclude param to resources-list for filtering https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41894 ## [1.0.0-cloud] - 2023-06-07 ### Changed diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index f88c87a768..cca0245c67 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -7,7 +7,7 @@ class ResourcesController < RestController def index # Rails 5 requires parameters to be explicitly permitted before converting # to Hash. See: https://stackoverflow.com/a/46029524 - allowed_params = %i[account kind limit offset search] + allowed_params = %i[account kind limit offset search exclude] options = params.permit(*allowed_params) .slice(*allowed_params).to_h.symbolize_keys diff --git a/app/models/resource.rb b/app/models/resource.rb index f6b403266c..746fc4eb65 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -88,13 +88,22 @@ def extended_associations # @param offset [Numeric] - an offset into the list of returned results # @param limit [Numeric] - a maximum number of results to return # @param search [String] - a search term in the resource id - def search account: nil, kind: nil, owner: nil, offset: nil, limit: nil, search: nil + def search account: nil, kind: nil, owner: nil, offset: nil, limit: nil, search: nil, exclude:nil scope = self # Filter by kind and account. scope = scope.where(Sequel.lit("account(resource_id) = ?", account)) if account scope = scope.where(Sequel.lit("kind(resource_id) = ?", kind)) if kind + # Filter by exclude + if exclude + exclude = exclude.split(',') + exclude.each do |item| + # scope is exclude when the resource id is like the item + scope = scope.exclude(Sequel.like(:resource_id, "%#{item}%")) + end + end + # Filter by owner if owner owners = Resource.from(::Sequel.function(:all_roles, owner.id)).select(:role_id) diff --git a/cucumber/api/features/resource_list.feature b/cucumber/api/features/resource_list.feature index f588a33bff..1bedccc9f1 100644 --- a/cucumber/api/features/resource_list.feature +++ b/cucumber/api/features/resource_list.feature @@ -9,7 +9,7 @@ Feature: List resources with various types of filtering @smoke Scenario: The resource list includes a new resource. - The most basic resource listing route returns all resources in an account. + The most basic resource listing route returns all resources in an account. Given I save my place in the audit log file for remote When I successfully GET "/resources/cucumber" @@ -224,3 +224,72 @@ Feature: List resources with various types of filtering [action@43868 result="success" operation="list"] cucumber:user:alice successfully listed resources with parameters: {:account=>"cucumber", :kind=>"test-resource", :count=>"true"} """ + + @acceptance + Scenario: The resource list is excluded for all directories. + Given I create a new resource called "target-resource-0" + # resources created, either in background and this scenario, are under test-resource directory + When I successfully GET "/resources/cucumber/test-resource?exclude=test-resource" + Then the result is empty + + @acceptance + Scenario: The resource list is excluded for a specific directory. + Given I create a new resource called "target-resource-0" + When I successfully GET "/resources/cucumber/test-resource?exclude=target-resource-0" + Then the resource list should not include the newest resource + + @acceptance + Scenario: The resource list is excluded for a not-exist directory. + Given I create a new resource called "target-resource-0" + When I successfully GET "/resources/cucumber/test-resource?exclude=random-branch" + Then the resource list should be include the all resources + + @acceptance + Scenario: The resource list is excluded for a list of directories. + Given I create a new resource called "target-resource-0" + And I create a new resource called "target-resource-1" + And I create a new resource called "target-resource-2" + When I successfully GET "/resources/cucumber/test-resource?exclude=target-resource-0,target-resource-1,target-resource-2" + Then I receive 3 resources + + @acceptance + Scenario: The resource list is excluded for different sub-branch directory. + Given I create a new "target-not-exclude" resource called "target-resource-0" + # resources created, either in background and this scenario, are under test-resource directory + When I successfully GET "/resources/cucumber?exclude=test-resource" + Then the resource list should have only the newest resource + + @acceptance + Scenario: The resource list is excluded for a list of directories and filter all branches. + Given I create a new resource called "target-resource-0" + And I create a new resource called "target-resource-1" + And I create a new resource called "target-resource-2" + # resources created, either in background and this scenario, are under test-resource directory + When I successfully GET "/resources/cucumber/test-resource?exclude=test-resource,target-resource-0" + Then the result is empty + + @acceptance + Scenario: The resource list is excluded for empty directory. + Given I create a new resource called "target-resource-0" + When I successfully GET "/resources/cucumber/test-resource?exclude=" + Then the resource list should be include the all resources + + @acceptance + Scenario: The resource list is excluded for special-character directory. + Given I create a new resource called "target-resource-./:;<=>?_`{|}]'()*+,-@#" + When I successfully GET "/resources/cucumber/test-resource?exclude=resource-./:;<=>?_`{|}]'()*+,-@#" + Then the resource list should not include the newest resource + + @acceptance + Scenario: The resource list is excluded and search a resource. + Given I create a new resource called "target-resource-0" + And I create a new resource called "find-me" + When I successfully GET "/resources/cucumber/test-resource?exclude=target-resource-0&search=find-me" + Then the resource list should include the newest resource + + @acceptance + Scenario: The resource list is excluded and search a resource. + Given I create a new resource called "target-resource-0" + And I create a new resource called "find-me" + When I successfully GET "/resources/cucumber/test-resource?exclude=target-resource-0&search=target-resource-0" + Then the result is empty \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/response_steps.rb b/cucumber/api/features/step_definitions/response_steps.rb index 73efa7140b..73fb148a17 100644 --- a/cucumber/api/features/step_definitions/response_steps.rb +++ b/cucumber/api/features/step_definitions/response_steps.rb @@ -70,3 +70,12 @@ data = Base64.decode64(@result[resource_id]) expect(data).to eq(@value) end + +Then(/^the resource list should be include the all resources$/) do + all_resources = [*@resources.values.map{|r| r.id}] + expect(@result.map{|r| r['id']}.count).to eq(all_resources.count) +end + +Then(/^the resource list should have only the newest resource$/) do + expect(@result.map{|r| r['id']}).to eq([@current_resource.id]) +end From c6afb0c0b5cc753e3822963255d20f28f25343cb Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 16 Jul 2023 11:23:59 +0300 Subject: [PATCH 060/665] Edge startup audit --- CHANGELOG.md | 6 +- app/controllers/edge_controller.rb | 8 +- .../audit/event/edge/edge_install_base.rb | 96 +++++++++++++++++++ app/models/audit/event/edge/edge_startup.rb | 24 +++++ app/models/edge.rb | 3 +- .../data_handlers/install_handler.rb | 25 ++++- 6 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 app/models/audit/event/edge/edge_install_base.rb create mode 100644 app/models/audit/event/edge/edge_startup.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c9e00cc0..366931c31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.1-cloud] - 2023-07-16 +## [1.0.2-cloud] - 2023-07-30 +### Changed +- Add audit when Edge reports installation completed + +## [1.0.1-cloud] - 2023-07-23 ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 - Pull Slosilo library to Conjur diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 1c8960707b..5f009e0ac7 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -189,11 +189,9 @@ def report_edge_data data_handlers = {'install' => EdgeLogic::DataHandlers::InstallHandler , 'ongoing' => EdgeLogic::DataHandlers::OngoingHandler} handler = data_handlers[url_params[:data_type]] raise BadRequest unless handler - begin - handler.new(logger).call(params, current_user.role_id, request.ip) - rescue Exceptions::RecordNotFound - raise RecordNotFound.new(host_name, message: "Edge for host #{current_user.role_id} not found") - end + + handler.new(logger).call(params, current_user.role_id, request.ip) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/data")) end diff --git a/app/models/audit/event/edge/edge_install_base.rb b/app/models/audit/event/edge/edge_install_base.rb new file mode 100644 index 0000000000..c595b59283 --- /dev/null +++ b/app/models/audit/event/edge/edge_install_base.rb @@ -0,0 +1,96 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class EdgeInstallBase + + def initialize( + edge_name:, + user: nil, + client_ip: nil, + error_message: nil + ) + @edge_name = edge_name + @user = user + @client_ip = client_ip + @error_message = error_message + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + Syslog::LOG_NOTICE + end + + def to_s + message + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def message + attempted_action.message( + success_msg: success_message, + failure_msg: failure_message, + error_msg: @error_message + ) + end + + def message_id + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def operation + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: success?, + operation: operation + ) + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def structured_data + { + SDID::AUTH => { user: @user }, + SDID::SUBJECT => {edge: @edge_name}, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/b753929806d0e42cdfde3f1a8dcdbf678f937e44/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + private + + def success_message + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def failure_message + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def success? + @error_message.nil? + end + end + end +end diff --git a/app/models/audit/event/edge/edge_startup.rb b/app/models/audit/event/edge/edge_startup.rb new file mode 100644 index 0000000000..cbedd55c34 --- /dev/null +++ b/app/models/audit/event/edge/edge_startup.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Audit + module Event + class EdgeStartup < EdgeInstallBase + def message_id + "installed" + end + + def operation + "install" + end + + def success_message + "Edge instance #{@edge_name} has been installed" + end + + def failure_message + "Edge instance #{@edge_name} install failed" + end + + end + end +end \ No newline at end of file diff --git a/app/models/edge.rb b/app/models/edge.rb index 8a0b01bb60..c78e694b1b 100644 --- a/app/models/edge.rb +++ b/app/models/edge.rb @@ -14,7 +14,8 @@ def new_edge(**values) end def get_by_hostname(hostname) - Edge.where(id: hostname_to_id(hostname)).first || raise(Exceptions::RecordNotFound) + Edge.where(id: hostname_to_id(hostname)).first || raise(Exceptions::RecordNotFound.new(hostname, + message: "Edge for host #{hostname} not found")) end def hostname_to_id(hostname) diff --git a/app/models/edge_logic/data_handlers/install_handler.rb b/app/models/edge_logic/data_handlers/install_handler.rb index a0b771bdd6..dbf8e53995 100644 --- a/app/models/edge_logic/data_handlers/install_handler.rb +++ b/app/models/edge_logic/data_handlers/install_handler.rb @@ -5,14 +5,31 @@ module DataHandlers class InstallHandler def initialize(logger) @logger = logger + @error_message = nil end def call(params, hostname, ip) - installation_date = params.require(:installation_date) - edge = Edge.get_by_hostname(hostname) - edge.update(installation_date: Time.at(installation_date)) + edge = nil + begin + edge = Edge.get_by_hostname(hostname) + installation_date = params.require(:installation_date) + edge.update(installation_date: Time.at(installation_date)) + rescue => e + @error_message = e.message + raise e + ensure + audit_installed(edge&.name, ip) + end + end + + private - #TODO write audit + def audit_installed(edge_name = "not-found", ip) + audit_params = { edge_name: edge_name, user: edge_name, client_ip: ip } + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::EdgeStartup.new( + **audit_params + )) end end end From dec860cdc1d42621b0eb989e1219af9191c8701e Mon Sep 17 00:00:00 2001 From: egvili Date: Tue, 18 Jul 2023 14:37:52 +0300 Subject: [PATCH 061/665] Add strict validation for report data params --- CHANGELOG.md | 4 ++-- app/controllers/concerns/params_validator.rb | 17 +++++++++++++++++ .../edge_logic/data_handlers/ongoing_handler.rb | 3 +++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 app/controllers/concerns/params_validator.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 366931c31e..aad5730d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.2-cloud] - 2023-07-30 +## [1.0.2-cloud] - 2023-07-18 ### Changed - Add audit when Edge reports installation completed -## [1.0.1-cloud] - 2023-07-23 +## [1.0.1-cloud] - 2023-07-18 ### Changed - Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 - Pull Slosilo library to Conjur diff --git a/app/controllers/concerns/params_validator.rb b/app/controllers/concerns/params_validator.rb new file mode 100644 index 0000000000..8ace1a7a15 --- /dev/null +++ b/app/controllers/concerns/params_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ParamsValidator + extend ActiveSupport::Concern + + # Validates given params based on validator. + # params may include body json which can be recursive, hence need to traverse recursively + def validate_params(params, validator) + params.each do |key, value| + if value.is_a?(Hash) # value is an item in a nested json from body + validate_params(value, validator) + else + raise ApplicationController::BadRequest unless validator.call(key, value) + end + end + end +end diff --git a/app/models/edge_logic/data_handlers/ongoing_handler.rb b/app/models/edge_logic/data_handlers/ongoing_handler.rb index b236aae111..56968e1805 100644 --- a/app/models/edge_logic/data_handlers/ongoing_handler.rb +++ b/app/models/edge_logic/data_handlers/ongoing_handler.rb @@ -3,6 +3,8 @@ module EdgeLogic module DataHandlers class OngoingHandler + + include ParamsValidator def initialize(logger) @logger = logger end @@ -12,6 +14,7 @@ def call(params, hostname, ip) allowed_params = [:account, :edge_version, :edge_container_type, edge_statistics: [:last_synch_time, cycle_requests: [:get_secret, :apikey_authenticate, :jwt_authenticate, :redirect]]] options = params.permit(*allowed_params).to_h + validate_params(options, ->(k, v) {v.is_a?(Numeric) || (v.is_a?(String) && v.length <= 20)}) edge = Edge.get_by_hostname(hostname) edge.record_edge_access(options, ip) From 522d631243b2f96c10be20d2ec7a6063085802e5 Mon Sep 17 00:00:00 2001 From: nofarvered Date: Thu, 20 Jul 2023 12:55:24 +0300 Subject: [PATCH 062/665] Max-edge-allowed endpoint --- CHANGELOG.md | 3 +- app/controllers/edge_controller.rb | 17 ++++++++ config/routes.rb | 2 + cucumber/api/features/edge.feature | 65 +++++++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad5730d19..b1245d7990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.2-cloud] - 2023-07-18 +## [1.0.2-cloud] - 2023-07-20 ### Changed +- Add max edges endpoint for multi edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41982_ - Add audit when Edge reports installation completed ## [1.0.1-cloud] - 2023-07-18 diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 5f009e0ac7..23970b7071 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -181,6 +181,23 @@ def all_edges version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) end + def max_edges_allowed + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/max-allowed")) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + validate_conjur_admin_group(options[:account]) + begin + id = options[:account] + ":variable:edge/edge-configuration/max-edge-allowed" + row = Secret.where(:resource_id.like(id)).last + secret_value = Slosilo::EncryptedAttributes.decrypt(row[:value], aad: id) + + render(plain: secret_value, content_type: "text/plain") + rescue Exceptions::RecordNotFound + raise RecordNotFound, "The request failed because max-edge-allowed secret doesn't exist" + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/max-allowed")) + end + def report_edge_data logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/data')) allowed_params = %i[account data_type] diff --git a/config/routes.rb b/config/routes.rb index 863e0bcfee..5618a4497c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ def matches?(request) get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' get "/edge/edges/:account" => 'edge#all_edges' + get "/edge/max-allowed/:account" => 'edge#max_edges_allowed' + post "/edge/data/:account" => 'edge#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 6124bc9687..3096e126cb 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -26,7 +26,11 @@ Feature: Fetching secrets from edge endpoint id: edge-host-abcd1234567890 annotations: authn/api-key: true - + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed - !grant role: !group edge/edge-hosts members: @@ -68,6 +72,7 @@ Feature: Fetching secrets from edge endpoint And I add the secret value "s4" to the resource "cucumber:variable:data/secret4" And I add the secret value "s5" to the resource "cucumber:variable:data/secret5" # secret6 has no value on purpose. Endpoint `all_secrets` should not return it + And I add the secret value "0" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" And I log out # Slosilo key @@ -671,3 +676,61 @@ Feature: Fetching secrets from edge endpoint Given I login as "some_user" When I GET "/edge/edges/cucumber" Then the HTTP response status code is 403 + + ###################### + # Max edges allowed + ###################### + + @negative @acceptance + Scenario: max edges allowed is permitted only to admins + Given I login as "admin_user" + When I GET "/edge/max-allowed/cucumber" + Then the HTTP response status code is 200 + Given I login as "some_user" + When I GET "/edge/max-allowed/cucumber" + Then the HTTP response status code is 403 + + @acceptance + Scenario: max edges allowed get zero value response + Given I login as "admin_user" + When I GET "/edge/max-allowed/cucumber" + Then the text result is: + """ + 0 + """ + + @acceptance + Scenario: max edges allowed get the secret after a value update as admin + Given I add the secret value "100" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" + And I login as "admin_user" + When I GET "/edge/max-allowed/cucumber" + Then the text result is: + """ + 100 + """ + + @acceptance + Scenario: admin user and other users don't have permission to read max-allowed-edges variable without the endpoint + # All users don't have any permission - so they can't get the variable + Given I login as "admin_user" + When I GET "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" + Then the HTTP response status code is 404 + Given I login as "some_user" + When I GET "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" + Then the HTTP response status code is 404 + + @acceptance + Scenario: admin user and other users don't have permission to change max-allowed-edges variable + # All users don't have any permission - so they can't set the variable + Given I login as "admin_user" + When I POST "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" with body: + """ + v-1 + """ + Then the HTTP response status code is 404 + Given I login as "some_user" + When I POST "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" with body: + """ + v-1 + """ + Then the HTTP response status code is 404 \ No newline at end of file From e4f39d4f3fd4255c37f123f3fe2ad58c41f6ab75 Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 23 Jul 2023 10:12:56 +0300 Subject: [PATCH 063/665] Endpoint for edge installation token --- CHANGELOG.md | 1 + app/controllers/edge_controller.rb | 24 ++++++++++ .../audit/event/edge/creds_generation.rb | 25 +++++++++++ app/models/edge.rb | 45 ++++++++++++++++--- config/routes.rb | 1 + spec/controllers/edge_controller_spec.rb | 30 ++++++++++++- spec/models/edge_spec.rb | 33 ++++++++++++++ 7 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 app/models/audit/event/edge/creds_generation.rb create mode 100644 spec/models/edge_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b1245d7990..241129d557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Add max edges endpoint for multi edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41982_ - Add audit when Edge reports installation completed +- Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 ## [1.0.1-cloud] - 2023-07-18 ### Changed diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 23970b7071..4fb45b07ec 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -170,6 +170,30 @@ def all_hosts end end + def generate_install_token + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/edge-creds")) + allowed_params = %i[account edge_name] + options = params.permit(*allowed_params).to_h.symbolize_keys + audit_params = { edge_name: options[:edge_name], user: current_user.role_id, client_ip: request.ip } + begin + validate_conjur_admin_group(options[:account]) + + edge = Edge[name: options[:edge_name]] || (raise RecordNotFound.new(options[:edge_name], message: "Edge #{options[:edge_name]} not found")) + installer_token = edge.get_installer_token(options[:account], request) + + edge_host_name = Role.username_from_roleid(edge.get_edge_host_name(options[:account])) + + rescue => e + audit_params[:error_message] = e.message + raise e + ensure + Audit.logger.log(Audit::Event::CredsGeneration.new(**audit_params)) + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edge-creds")) + response.set_header("Content-Encoding", "base64") + render(plain: Base64.strict_encode64(edge_host_name + ":" + installer_token)) + end + def all_edges logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/edges")) allowed_params = %i[account] diff --git a/app/models/audit/event/edge/creds_generation.rb b/app/models/audit/event/edge/creds_generation.rb new file mode 100644 index 0000000000..eb48ddd147 --- /dev/null +++ b/app/models/audit/event/edge/creds_generation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +require_relative 'edge_install_base' + +module Audit + module Event + class CredsGeneration < EdgeInstallBase + def message_id + "creds-generated" + end + + def operation + "create" + end + + def success_message + "User #{@user} successfully generated installation token for Edge named #{@edge_name}" + end + + def failure_message + "User #{@user} failed to generate token for Edge instance #{@edge_name}" + end + + end + end +end \ No newline at end of file diff --git a/app/models/edge.rb b/app/models/edge.rb index c78e694b1b..207bc87311 100644 --- a/app/models/edge.rb +++ b/app/models/edge.rb @@ -4,10 +4,10 @@ class Edge < Sequel::Model + EDGE_HOST_PATTERN = "ACCOUNT:host:edge/edge-IDENTIFIER/edge-host-IDENTIFIER" + EDGE_INSTALLER_HOST_PATTERN = "ACCOUNT:host:edge/edge-installer-IDENTIFIER/edge-installer-host-IDENTIFIER" class << self - EDGE_HOST_PREFIX = 'edge-host-' - def new_edge(**values) values[:id] ||= SecureRandom.uuid Edge.insert(**values) @@ -19,13 +19,10 @@ def get_by_hostname(hostname) end def hostname_to_id(hostname) - regex = /(?<=#{EDGE_HOST_PREFIX})(.+)/ + regex = Regexp.new(EDGE_HOST_PATTERN.sub("ACCOUNT", '\w+').gsub("IDENTIFIER","(.+)")) hostname.match(regex)&.captures&.first end - def id_to_hostname(id) - EDGE_HOST_PREFIX + id - end end def record_edge_access(data, ip) @@ -37,4 +34,40 @@ def record_edge_access(data, ip) self.save end + + def get_edge_host_name(account) + EDGE_HOST_PATTERN.sub("ACCOUNT", account).gsub("IDENTIFIER", self.id) + end + + def get_edge_installer_host_name(account) + EDGE_INSTALLER_HOST_PATTERN.sub("ACCOUNT", account).gsub("IDENTIFIER", self.id) + end + + def get_installer_token(account, request) + installer_host_full_name = self.get_edge_installer_host_name(account) + installer_name = Role.username_from_roleid(installer_host_full_name) + installer_role = Role[installer_host_full_name] + # Authenticate + auth_input = Authentication::AuthenticatorInput.new( + authenticator_name: Authentication::Common.default_authenticator_name, + service_id: nil, + account: account, + username: installer_name, + credentials: installer_role.api_key, + client_ip: request.ip, + request: request + ) + installer_token = new_authenticate.call( + authenticator_input: auth_input, + authenticators: Authentication::InstalledAuthenticators.authenticators(ENV), + enabled_authenticators: Authentication::InstalledAuthenticators.enabled_authenticators_str + ) + Base64.strict_encode64(installer_token.to_json) + end + + private + + def new_authenticate + Authentication::Authenticate.new + end end diff --git a/config/routes.rb b/config/routes.rb index 5618a4497c..6a3d0bc622 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,6 +80,7 @@ def matches?(request) get "/secrets" => 'secrets#batch' get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' + get "edge/edge-creds/:account/:edge_name" => 'edge#generate_install_token' get "/edge/edges/:account" => 'edge#all_edges' get "/edge/max-allowed/:account" => 'edge#max_edges_allowed' diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 644e68ea31..7c0d42252d 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -4,7 +4,7 @@ describe EdgeController, :type => :request do let(:account) { "rspec" } - let(:host_id) {"#{account}:host:edge/edge-host-1234"} + let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} let(:other_host_id) {"#{account}:host:data/other"} let(:admin_user_id) {"#{account}:user:admin_user"} @@ -34,6 +34,10 @@ "/edge/data/#{account}" end + let(:edge_creds) do + "/edge/edge-creds/#{account}" + end + let(:init_prev_key) do Slosilo[token_id(account, "host", "previous")] ||= Slosilo::Key.new @@ -113,6 +117,30 @@ def send_request_with_correct_role end end + context "Installation" do + before do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + Edge.new_edge(name: "edgy", id: 1234, version: "latest", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + end + + it "Generate script error cases" do + #Missing edge + get("#{edge_creds}/non-existent", env: token_auth_header(role: @admin_user, is_user: true)) + expect(response.code).to eq("404") + + #Not admin + get("#{edge_creds}/edgy", env: token_auth_header(role: @other_user, is_user: true)) + expect(response.code).to eq("403") + + #Wrong account + get("/edge/edge-creds/tomato/edgy", env: token_auth_header(role: @admin_user, is_user: true)) + expect(response.code).to eq("403") + end + end + context "Visibility" do before do Role.create(role_id: "#{account}:group:edge/edge-hosts") diff --git a/spec/models/edge_spec.rb b/spec/models/edge_spec.rb new file mode 100644 index 0000000000..dbf9c5f387 --- /dev/null +++ b/spec/models/edge_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "EdgeObject" do + let(:identifier) {"1234"} + let(:account) { "rspec" } + let(:request) { double(ip: "1.1.1.1") } + let(:host_id) {"#{account}:host:edge/edge-#{identifier}/edge-host-#{identifier}"} + let(:installer_host_id) {"#{account}:host:edge/edge-installer-#{identifier}/edge-installer-host-#{identifier}"} + + before do + Edge.new_edge(id: identifier, name: "Edgy") + Role.find_or_create(role_id: host_id) + Role.find_or_create(role_id: installer_host_id) + end + + subject { Edge[identifier] } + + context "Edge installation" do + it "Generate Edge script" do + authenticate_mock = double(Authentication::Authenticate) + subject_mock = subject + allow(subject_mock).to receive(:new_authenticate).and_return(authenticate_mock) + expect(authenticate_mock).to receive(:call).with(anything) do |input| + expect(input[:authenticator_input][:username]).to eq(Role.username_from_roleid(installer_host_id)) + expect(input[:authenticator_input][:credentials]).to eq(Role[installer_host_id].api_key) + end + + expect{ subject_mock.get_installer_token(account, request) }.to_not raise_error + end + end +end From ae1b1811ec70f5b38c4f6869bb302bb43f101e77 Mon Sep 17 00:00:00 2001 From: egvili Date: Mon, 24 Jul 2023 14:50:52 +0300 Subject: [PATCH 064/665] Sort edges --- app/controllers/edge_controller.rb | 2 +- spec/controllers/edge_controller_spec.rb | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 4fb45b07ec..46f4397a83 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -200,7 +200,7 @@ def all_edges options = params.permit(*allowed_params).to_h.symbolize_keys validate_conjur_admin_group(options[:account]) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edges")) - render(json: Edge.all.map{|edge| + render(json: Edge.order(:name).all.map{|edge| {name: edge.name, ip: edge.ip, last_sync: edge.last_sync.to_i, version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 7c0d42252d..a817375f89 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -181,14 +181,24 @@ def send_request_with_correct_role end it "List endpoint works" do + # Add some more edges + Edge.new_edge(name: "hedge", id: 7777) + Edge.new_edge(name: "grudge", id: 8888) + Edge.new_edge(name: "fudge", id: 9999) + get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) expect(response.code).to eq("200") resp = JSON.parse(response.body) - expect(resp.size).to eq(1) + expect(resp.size).to eq(4) + expect(resp[0]['name']).to eq('edgy') expect(resp[0]['last_sync']).to eq(222222222) expect(resp[0]['version']).to eq("latest") expect(resp[0]['platform']).to eq("podman") + + expect(resp[1]['name']).to eq('fudge') + expect(resp[2]['name']).to eq('grudge') + expect(resp[3]['name']).to eq('hedge') end it "Reported data appears on list" do From bf121cd9cb6aa7632992c40fcc43e6c93f7e34d6 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 26 Jul 2023 08:39:03 +0300 Subject: [PATCH 065/665] ONYX-41980- Create edge host endpoint --- CHANGELOG.md | 6 +- .../concerns/extract_edge_resources.rb | 8 +++ .../concerns/find_edge_policy_resource.rb | 32 ++++++++++ app/controllers/edge_controller.rb | 10 ++- app/controllers/edge_creator_controller.rb | 63 +++++++++++++++++++ app/controllers/hosts_controller.rb | 14 ++--- app/controllers/wrappers/policy_wrapper.rb | 31 +++++++++ .../policy-templates/edge/create_edge.rb | 40 ++++++++++++ app/models/audit/event/edge/edge_creation.rb | 24 +++++++ app/models/edge.rb | 2 + config/routes.rb | 1 + cucumber/api/features/edge_create.feature | 60 ++++++++++++++++++ .../step_definitions/edge_create_step.rb | 13 ++++ .../concerns/find_edge_policy_concern_spec.rb | 32 ++++++++++ 14 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 app/controllers/concerns/extract_edge_resources.rb create mode 100644 app/controllers/concerns/find_edge_policy_resource.rb create mode 100644 app/controllers/edge_creator_controller.rb create mode 100644 app/domain/policy-templates/edge/create_edge.rb create mode 100644 app/models/audit/event/edge/edge_creation.rb create mode 100644 cucumber/api/features/edge_create.feature create mode 100644 cucumber/api/features/step_definitions/edge_create_step.rb create mode 100644 spec/controllers/concerns/find_edge_policy_concern_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 241129d557..030937fb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.3-cloud] - 2023-07-30 +### Added +- Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 +- Endpoint for creating edge host https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41980 + ## [1.0.2-cloud] - 2023-07-20 ### Changed - Add max edges endpoint for multi edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41982_ - Add audit when Edge reports installation completed -- Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 ## [1.0.1-cloud] - 2023-07-18 ### Changed diff --git a/app/controllers/concerns/extract_edge_resources.rb b/app/controllers/concerns/extract_edge_resources.rb new file mode 100644 index 0000000000..1d97193014 --- /dev/null +++ b/app/controllers/concerns/extract_edge_resources.rb @@ -0,0 +1,8 @@ +module ExtractEdgeResources + extend ActiveSupport::Concern + + def extract_max_edge_value(account) + id = account + ":variable:edge/edge-configuration/max-edge-allowed" + Secret.where(:resource_id.like(id)).last.value + end +end diff --git a/app/controllers/concerns/find_edge_policy_resource.rb b/app/controllers/concerns/find_edge_policy_resource.rb new file mode 100644 index 0000000000..7c17336507 --- /dev/null +++ b/app/controllers/concerns/find_edge_policy_resource.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module FindEdgePolicyResource + extend ActiveSupport::Concern + + def resource_id + [ params[:account], "policy", "edge" ].join(":") + end + + + protected + + def resource + raise Exceptions::RecordNotFound, resource_id unless resource_visible? + + resource! + end + + def resource_exists? + Resource[resource_id] ? true : false + end + + def resource_visible? + return is_role_member_of_group(account, current_user.id, ':group:Conjur_Cloud_Admins') + end + + private + + def resource! + @resource ||= Resource[resource_id] + end +end diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 46f4397a83..c2d89d9e2d 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true class EdgeController < RestController + include AccountValidator + include BodyParser include Cryptography include EdgeValidator - include AccountValidator + include ExtractEdgeResources include GroupMembershipValidator - include BodyParser def slosilo_keys logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) @@ -211,10 +212,7 @@ def max_edges_allowed options = params.permit(*allowed_params).to_h.symbolize_keys validate_conjur_admin_group(options[:account]) begin - id = options[:account] + ":variable:edge/edge-configuration/max-edge-allowed" - row = Secret.where(:resource_id.like(id)).last - secret_value = Slosilo::EncryptedAttributes.decrypt(row[:value], aad: id) - + secret_value = extract_max_edge_value(options[:account]) render(plain: secret_value, content_type: "text/plain") rescue Exceptions::RecordNotFound raise RecordNotFound, "The request failed because max-edge-allowed secret doesn't exist" diff --git a/app/controllers/edge_creator_controller.rb b/app/controllers/edge_creator_controller.rb new file mode 100644 index 0000000000..9470714a59 --- /dev/null +++ b/app/controllers/edge_creator_controller.rb @@ -0,0 +1,63 @@ + +require_relative '../controllers/wrappers/policy_wrapper' + +class EdgeCreatorController < RestController + include AccountValidator + include BodyParser + include EdgeValidator + include ExtractEdgeResources + include FindEdgePolicyResource + include GroupMembershipValidator + include PolicyWrapper + + #this endpoint loads a policy with the edge host values + adds the edge name to Edge table + def create_edge + logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/create')) + allowed_params = %i[account edge_name] + url_params = params.permit(*allowed_params) + validate_conjur_admin_group(url_params[:account]) + params[:identifier] = "edge" + edge_name = params[:edge_name] + + begin + validate_max_edge_allowed(url_params[:account]) + Edge.new_edge(name: edge_name) + edge = Edge[name: edge_name] + add_edge_host_policy(edge[:id]) + rescue => e + @error_message = e.message + raise e + ensure + created_audit(edge_name) + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/create")) + head :created + end + + private + + def validate_max_edge_allowed(account) + max_edges = extract_max_edge_value(account) + table_size = Edge.count + raise UnprocessableEntity, "Edge number exceeded max edge allowed" unless table_size < max_edges.to_i + end + + def add_edge_host_policy(host_id) + input = input_post_yaml(host_id) + submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateEdge.new(), input) + end + + def input_post_yaml(json_body) + { + "edge_identifier" => json_body + } + end + + def created_audit(edge_name = "not-found") + audit_params = { edge_name: edge_name, user: current_user.role_id, client_ip: request.ip} + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::EdgeCreation.new( + **audit_params + )) + end +end \ No newline at end of file diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index dc23373462..cae7b3a4ec 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -1,21 +1,17 @@ # frozen_string_literal: true require_relative '../controllers/wrappers/policy_wrapper' require_relative '../controllers/wrappers/policy_audit' -require_relative '../controllers/wrappers/templates_renderer' # class HostsController < RestController include AuthorizeResource - include PolicyAudit - include PolicyWrapper - include PolicyTemplates::TemplatesRenderer include BodyParser include FindPolicyResource + include PolicyAudit + include PolicyWrapper before_action :current_user before_action :find_or_create_root_policy - rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load - set_default_content_type_for_path(%r{^/hosts}, 'application/json') def post @@ -26,9 +22,7 @@ def post .to_h.symbolize_keys validateId(params[:id]) input = input_post_yaml(params) - result_yaml = renderer(PolicyTemplates::CreateHost.new(), input) - set_raw_policy(result_yaml) - result = load_policy(Loader::CreatePolicy, false) + result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input) policy = result[:policy] audit_success(policy) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("hosts/:account/*identifier")) @@ -50,7 +44,7 @@ def validateId(id) end def input_post_yaml(json_body) - return input = { + { "id" => json_body[:id], "annotations" => json_body[:annotations], "groups" => json_body[:groups], diff --git a/app/controllers/wrappers/policy_wrapper.rb b/app/controllers/wrappers/policy_wrapper.rb index 90a4cb11a5..ba97027d24 100644 --- a/app/controllers/wrappers/policy_wrapper.rb +++ b/app/controllers/wrappers/policy_wrapper.rb @@ -2,8 +2,16 @@ # Policy wrapper to include stuff already existing in policies_controller.rb, # to allow flexibility for rest endpoint who will use it differently + +require_relative 'templates_renderer' + module PolicyWrapper extend ActiveSupport::Concern + include PolicyTemplates::TemplatesRenderer + + included do + rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load + end def load_policy(loader_class, delete_permitted) policy = save_submitted_policy(delete_permitted: delete_permitted) @@ -12,6 +20,13 @@ def load_policy(loader_class, delete_permitted) { created_roles: created_roles, policy: policy } end + def submit_policy(policy_loader, policy_tamplate, input) + result_yaml = renderer(policy_tamplate, input) + set_raw_policy(result_yaml) + result = load_policy(policy_loader, false) + result + end + def raw_policy @raw_policy end @@ -50,4 +65,20 @@ def create_roles(actor_roles) memo[role_id] = { id: role_id, api_key: credentials.api_key } end end +end + +def concurrent_load(_exception) + response.headers['Retry-After'] = retry_delay + render(json: { + error: { + code: "policy_conflict", + message: "Concurrent policy load in progress, please retry" + } + }, status: :conflict) +end + +# Delay in seconds to advise the client to wait before retrying on conflict. +# It's randomized to avoid request bunching. +def retry_delay + rand(1..8) end \ No newline at end of file diff --git a/app/domain/policy-templates/edge/create_edge.rb b/app/domain/policy-templates/edge/create_edge.rb new file mode 100644 index 0000000000..e2ceb11f75 --- /dev/null +++ b/app/domain/policy-templates/edge/create_edge.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateEdge < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !policy + id: edge-<%= edge_identifier %> + body: + - !host + id: edge-host-<%= edge_identifier %> + annotations: + authn/api-key: true + - !policy + id: edge-installer-<%= edge_identifier %> + body: + - !host + id: edge-installer-host-<%= edge_identifier %> + annotations: + authn/api-key: true + + - !grant + role: !group edge-hosts + members: + - !host edge-<%= edge_identifier %>/edge-host-<%= edge_identifier %> + + - !grant + role: !group edge-installer-group + members: + - !host edge-installer-<%= edge_identifier %>/edge-installer-host-<%= edge_identifier %> + + - !permit + role: !host edge-installer-<%= edge_identifier %>/edge-installer-host-<%= edge_identifier %> + privileges: [ update ] + resources: !host edge-<%= edge_identifier %>/edge-host-<%= edge_identifier %> + TEMPLATE + end + end +end diff --git a/app/models/audit/event/edge/edge_creation.rb b/app/models/audit/event/edge/edge_creation.rb new file mode 100644 index 0000000000..c331bf4a90 --- /dev/null +++ b/app/models/audit/event/edge/edge_creation.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require_relative 'edge_install_base' + +module Audit + module Event + class EdgeCreation < EdgeInstallBase + def message_id + "created" + end + + def operation + "created" + end + + def success_message + "User #{@user} successfully created new Edge instance named #{@edge_name}" + end + + def failure_message + "User #{@user} failed to create new Edge instance named #{@edge_name}" + end + end + end +end diff --git a/app/models/edge.rb b/app/models/edge.rb index 207bc87311..32dd12af42 100644 --- a/app/models/edge.rb +++ b/app/models/edge.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'pg' require 'securerandom' require 'sequel' @@ -9,6 +10,7 @@ class Edge < Sequel::Model class << self def new_edge(**values) + raise ArgumentError, 'Edge name is not provided' unless values[:name] values[:id] ||= SecureRandom.uuid Edge.insert(**values) end diff --git a/config/routes.rb b/config/routes.rb index 6a3d0bc622..98aa91cb01 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,6 +78,7 @@ def matches?(request) get "/secrets/:account/:kind/*identifier" => 'secrets#show' post "/secrets/:account/:kind/*identifier" => 'secrets#create' get "/secrets" => 'secrets#batch' + post "/edge/create/:account/:edge_name" => 'edge_creator#create_edge' get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' get "edge/edge-creds/:account/:edge_name" => 'edge#generate_install_token' diff --git a/cucumber/api/features/edge_create.feature b/cucumber/api/features/edge_create.feature new file mode 100644 index 0000000000..12d55cacf1 --- /dev/null +++ b/cucumber/api/features/edge_create.feature @@ -0,0 +1,60 @@ +Feature: Create edge host endpoint + + Background: + Given I create a new user "some_user" + And I create a new user "admin_user" + And I have host "data/some_host1" + And I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !group edge-installer-group + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !variable edge-cycle-interval + - !permit + role: !group edge-hosts + privileges: [ read, execute ] + resources: !variable edge-configuration/edge-cycle-interval + + - !group Conjur_Cloud_Admins + - !grant + role: !group Conjur_Cloud_Admins + member: !user admin_user + """ + And I add the secret value "3" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" + + @acceptance + Scenario: Create edge host return 201 OK + Given I login as "admin_user" + When I POST "/edge/create/cucumber/edgy" + Then the HTTP response status code is 201 + Then Edge name "edgy" data exists in db + + @negative @acceptance + Scenario: Create edge with existing name return 409 + Given I login as "admin_user" + When I POST "/edge/create/cucumber/edgy" + Then the HTTP response status code is 409 + + @negative @acceptance + Scenario: Create edge with non admin_user return 403 + Given I login as "host/data/some_host1" + When I POST "/edge/create/cucumber/edgy1" + Then the HTTP response status code is 403 + + @negative @acceptance + Scenario: Exceeding max edges allowed return 422 + Given I login as "admin_user" + When I POST "/edge/create/cucumber/edgy1" + Then the HTTP response status code is 201 + When I POST "/edge/create/cucumber/edgy2" + Then the HTTP response status code is 201 + When I POST "/edge/create/cucumber/edgy3" + Then the HTTP response status code is 422 \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/edge_create_step.rb b/cucumber/api/features/step_definitions/edge_create_step.rb new file mode 100644 index 0000000000..4869c674cd --- /dev/null +++ b/cucumber/api/features/step_definitions/edge_create_step.rb @@ -0,0 +1,13 @@ + +Then(/^Edge name "([^"]*)" data exists in db$/) do |arg| + edge = Edge[name: arg] + id = edge[:id] + expect(edge).not_to be_nil + + res = Resource.where(Sequel.like(:resource_id, "cucumber:policy:edge/edge-#{id}")).count + expect(res).to be > 0 + res = Resource.where(Sequel.like(:resource_id, "cucumber:host:edge/edge-#{id}/edge-host-#{id}")).count + expect(res).to be > 0 + res = Resource.where(Sequel.like(:resource_id, "cucumber:host:edge/edge-installer-#{id}/edge-installer-host-#{id}")) + expect(res).to be > 0 +end diff --git a/spec/controllers/concerns/find_edge_policy_concern_spec.rb b/spec/controllers/concerns/find_edge_policy_concern_spec.rb new file mode 100644 index 0000000000..f5eb45a421 --- /dev/null +++ b/spec/controllers/concerns/find_edge_policy_concern_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe FindEdgePolicyResource do + context "when resource cannot be found" do + let(:resource) { nil } + describe '#resource' do + it "raises an error" do + expect { controller.send(:resource) } + .to raise_error(Exceptions::RecordNotFound) + end + end + end + + before do + allow(Resource).to receive(:[]).with(resource_id).and_return(resource) + allow(controller).to receive(:resource_id) { resource_id } + allow(controller).to receive(:account).and_return('rspec') + mock_user = double('User', id: 456) + allow(controller).to receive(:current_user).and_return(mock_user) + allow(controller).to receive(:is_role_member_of_group).and_return(false) + end + + let(:resource_id) { 'test:kind:resource' } + + # Test controller class + class Controller + include FindEdgePolicyResource + end + + subject(:controller) { Controller.new } +end From 8f82dd3a43f896fc566c6d73301c053c3b34e7b4 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Thu, 27 Jul 2023 16:53:40 +0300 Subject: [PATCH 066/665] ONYX-41980- Create edge host endpoint --- app/controllers/wrappers/policy_wrapper.rb | 16 ++++++++-------- app/models/edge.rb | 8 ++++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/controllers/wrappers/policy_wrapper.rb b/app/controllers/wrappers/policy_wrapper.rb index ba97027d24..3f90962157 100644 --- a/app/controllers/wrappers/policy_wrapper.rb +++ b/app/controllers/wrappers/policy_wrapper.rb @@ -9,15 +9,15 @@ module PolicyWrapper extend ActiveSupport::Concern include PolicyTemplates::TemplatesRenderer - included do - rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load - end - def load_policy(loader_class, delete_permitted) - policy = save_submitted_policy(delete_permitted: delete_permitted) - loaded_policy = loader_class.from_policy(policy) - created_roles = perform(loaded_policy) - { created_roles: created_roles, policy: policy } + begin + policy = save_submitted_policy(delete_permitted: delete_permitted) + loaded_policy = loader_class.from_policy(policy) + created_roles = perform(loaded_policy) + { created_roles: created_roles, policy: policy } + rescue Sequel::UniqueConstraintViolation => e + concurrent_load + end end def submit_policy(policy_loader, policy_tamplate, input) diff --git a/app/models/edge.rb b/app/models/edge.rb index 32dd12af42..975545a815 100644 --- a/app/models/edge.rb +++ b/app/models/edge.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -require 'pg' require 'securerandom' require 'sequel' @@ -11,8 +10,13 @@ class << self def new_edge(**values) raise ArgumentError, 'Edge name is not provided' unless values[:name] + values[:id] ||= SecureRandom.uuid - Edge.insert(**values) + begin + Edge.insert(**values) + rescue Sequel::UniqueConstraintViolation => e + raise Exceptions::RecordExists.new("edge", values[:name]) + end end def get_by_hostname(hostname) From 9b4ef22fd22baa52c5008113d328710cad1244d1 Mon Sep 17 00:00:00 2001 From: egvili Date: Thu, 27 Jul 2023 16:06:00 +0300 Subject: [PATCH 067/665] Audit tests --- app/models/audit/event/edge/edge_creation.rb | 2 +- cucumber/api/features/edge_create.feature | 115 +++++++++++++++++- .../step_definitions/edge_create_step.rb | 11 ++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/app/models/audit/event/edge/edge_creation.rb b/app/models/audit/event/edge/edge_creation.rb index c331bf4a90..3828202cdd 100644 --- a/app/models/audit/event/edge/edge_creation.rb +++ b/app/models/audit/event/edge/edge_creation.rb @@ -9,7 +9,7 @@ def message_id end def operation - "created" + "create" end def success_message diff --git a/cucumber/api/features/edge_create.feature b/cucumber/api/features/edge_create.feature index 12d55cacf1..5f166ab4d6 100644 --- a/cucumber/api/features/edge_create.feature +++ b/cucumber/api/features/edge_create.feature @@ -1,4 +1,4 @@ -Feature: Create edge host endpoint +Feature: Create edge process Background: Given I create a new user "some_user" @@ -33,16 +33,37 @@ Feature: Create edge host endpoint @acceptance Scenario: Create edge host return 201 OK Given I login as "admin_user" + And I save my place in the audit log file for remote When I POST "/edge/create/cucumber/edgy" Then the HTTP response status code is 201 - Then Edge name "edgy" data exists in db + And Edge name "edgy" data exists in db + And there is an audit record matching: + """ + <85>1 * * conjur * created + [auth@43868 user="cucumber:user:admin_user"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="create"] + User cucumber:user:admin_user successfully created new Edge instance named edgy + """ @negative @acceptance Scenario: Create edge with existing name return 409 Given I login as "admin_user" + And I save my place in the audit log file for remote + When I POST "/edge/create/cucumber/edgy" + Then the HTTP response status code is 201 When I POST "/edge/create/cucumber/edgy" Then the HTTP response status code is 409 - + And there is an audit record matching: + """ + <85>1 * * conjur * created + [auth@43868 user="cucumber:user:admin_user"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="create"] + User cucumber:user:admin_user failed to create new Edge instance named edgy + """ @negative @acceptance Scenario: Create edge with non admin_user return 403 Given I login as "host/data/some_host1" @@ -57,4 +78,90 @@ Feature: Create edge host endpoint When I POST "/edge/create/cucumber/edgy2" Then the HTTP response status code is 201 When I POST "/edge/create/cucumber/edgy3" - Then the HTTP response status code is 422 \ No newline at end of file + Then the HTTP response status code is 201 + When I POST "/edge/create/cucumber/edgy4" + Then the HTTP response status code is 422 + + @acceptance + Scenario: Script generation success emits audit + Given I login as "admin_user" + And I save my place in the audit log file for remote + When I POST "/edge/create/cucumber/edgy" + Then the HTTP response status code is 201 + When I GET "/edge/edge-creds/cucumber/edgy" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <85>1 * * conjur * creds-generated + [auth@43868 user="cucumber:user:admin_user"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="create"] + User cucumber:user:admin_user successfully generated installation token for Edge named edgy + """ + + @negative + Scenario: Script generation failure emits audit + Given I login as "admin_user" + When I POST "/edge/create/cucumber/edgy" + Then the HTTP response status code is 201 + When I log out + And I login as "some_user" + And I save my place in the audit log file for remote + When I GET "/edge/edge-creds/cucumber/edgy" + Then the HTTP response status code is 403 + And there is an audit record matching: + """ + <85>1 * * conjur * creds-generated + [auth@43868 user="cucumber:user:some_user"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="create"] + User cucumber:user:some_user failed to generate token for Edge instance edgy + """ + + @acceptance + Scenario: Edge start report success emits audit + Given I login as "admin_user" + And I POST "/edge/create/cucumber/edgy" + And the HTTP response status code is 201 + And I save my place in the audit log file for remote + When I login as the host associated with Edge "edgy" + And I set the "Content-Type" header to "application/json" + And I POST "/edge/data/cucumber?data_type=install" with body: + """ + { "installation_date" : 1111111 } + """ + Then the HTTP response status code is 204 + And there is an audit record matching: + """ + <85>1 * * conjur * installed + [auth@43868 user="edgy"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="install"] + Edge instance edgy has been installed + """ + + @negative + Scenario: Edge start report failure emits audit + Given I login as "admin_user" + And I POST "/edge/create/cucumber/edgy" + And the HTTP response status code is 201 + And I save my place in the audit log file for remote + When I login as the host associated with Edge "edgy" + And I set the "Content-Type" header to "application/json" + And I POST "/edge/data/cucumber?data_type=install" with body: + """ + { "installation_bad_date" : 1111111 } + """ + Then the HTTP response status code is 422 + And there is an audit record matching: + """ + <85>1 * * conjur * installed + [auth@43868 user="edgy"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="install"] + Edge instance edgy install failed + """ \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/edge_create_step.rb b/cucumber/api/features/step_definitions/edge_create_step.rb index 4869c674cd..0a80cde458 100644 --- a/cucumber/api/features/step_definitions/edge_create_step.rb +++ b/cucumber/api/features/step_definitions/edge_create_step.rb @@ -11,3 +11,14 @@ res = Resource.where(Sequel.like(:resource_id, "cucumber:host:edge/edge-installer-#{id}/edge-installer-host-#{id}")) expect(res).to be > 0 end + +When(/^I login as the host associated with Edge "([^"]*)"$/) do |edge_name| + edge = Edge[name: edge_name] + hostname = edge.get_edge_host_name("cucumber") + @current_user = Role.with_pk!(hostname) + Credentials.new(role: @current_user).save unless @current_user.credentials +end + +After do |scenario| + Edge.dataset.delete +end From 42cc18a60ad762a1b969ffb4fed5fb212768a8f8 Mon Sep 17 00:00:00 2001 From: ygeva Date: Mon, 31 Jul 2023 10:58:22 +0300 Subject: [PATCH 068/665] Fix workloads --- .../concerns/find_policy_resource.rb | 28 +++++++- app/controllers/edge_creator_controller.rb | 2 +- app/controllers/hosts_controller.rb | 53 +++++++++++--- app/controllers/wrappers/policy_wrapper.rb | 10 +-- .../grants/grant_host_safe.rb | 14 ++++ .../policy-templates/hosts/create_host.rb | 14 ---- .../concerns/find_policy_resource_spec.rb | 28 -------- spec/controllers/hosts_controller_spec.rb | 70 +++++++++---------- 8 files changed, 122 insertions(+), 97 deletions(-) create mode 100644 app/domain/policy-templates/grants/grant_host_safe.rb delete mode 100644 spec/controllers/concerns/find_policy_resource_spec.rb diff --git a/app/controllers/concerns/find_policy_resource.rb b/app/controllers/concerns/find_policy_resource.rb index 1057afecdd..12ef86aaae 100644 --- a/app/controllers/concerns/find_policy_resource.rb +++ b/app/controllers/concerns/find_policy_resource.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module FindPolicyResource - include FindResource extend ActiveSupport::Concern - def resource_id - [ params[:account], "policy", params[:identifier] ].join(":") + def resource_id(location) + [ params[:account], "policy", location ].join(":") end def find_or_create_root_policy @@ -15,4 +14,27 @@ def account @account ||= params[:account] end + + protected + + def resource(location) + raise Exceptions::RecordNotFound, resource_id(location) unless resource_visible?(location) + + resource!(location) + end + + def resource_exists?(location) + Resource[resource_id(location)] ? true : false + end + + def resource_visible?(location) + @resource_visible = resource!(location) && @resource.visible_to?(current_user) + end + + private + + def resource!(location) + @resource = Resource[resource_id(location)] + end + end \ No newline at end of file diff --git a/app/controllers/edge_creator_controller.rb b/app/controllers/edge_creator_controller.rb index 9470714a59..1143689646 100644 --- a/app/controllers/edge_creator_controller.rb +++ b/app/controllers/edge_creator_controller.rb @@ -44,7 +44,7 @@ def validate_max_edge_allowed(account) def add_edge_host_policy(host_id) input = input_post_yaml(host_id) - submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateEdge.new(), input) + submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateEdge.new(), input,resource) end def input_post_yaml(json_body) diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index cae7b3a4ec..368048f298 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -17,14 +17,18 @@ class HostsController < RestController def post logger.info(LogMessages::Endpoints::EndpointRequested.new("hosts/:account/*identifier")) action = :create - authorize(action, resource) - params.permit(:identifier, :account, :id, :annotations, :groups, :layers) + params.permit(:identifier, :account, :id, :annotations, :safes) .to_h.symbolize_keys + authorize(action, resource(params[:identifier])) validateId(params[:id]) - input = input_post_yaml(params) - result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input) - policy = result[:policy] - audit_success(policy) + input = input_host_create(params) + result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input, resource(params[:identifier])) + hostPolicy = result[:policy] + grantPolicies = grantHostToSafes(params) + audit_success(hostPolicy) + grantPolicies.each do |policy| + audit_success(policy) + end logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("hosts/:account/*identifier")) render(json: { created_roles: result[:created_roles] @@ -43,11 +47,42 @@ def validateId(id) end end -def input_post_yaml(json_body) +def input_host_create(json_body) { "id" => json_body[:id], "annotations" => json_body[:annotations], - "groups" => json_body[:groups], - "layers" => json_body[:layers] } end + +def input_grant_safe(host_id) + { + "id" => host_id, + } +end + +def build_host_name(params) + path = [] + path << params[:identifier] unless params[:identifier] == "root" + path << params[:id] + "/" + path.join('/') +end + +def grantHostToSafes(params) + safes = params[:safes] + policies = [] + if safes.nil? + return policies + end + if !safes.is_a?(Array) + raise ApplicationController::UnprocessableEntity, "safes must be an array." + end + action = :update + host_id = build_host_name(params) + safes.each do |safe| + authorize(action, resource(safe)) + input = input_grant_safe(host_id) + result = submit_policy(Loader::CreatePolicy, PolicyTemplates::GrantHostSafe.new(), input, resource(safe)) + policies << result[:policy] + end + return policies +end diff --git a/app/controllers/wrappers/policy_wrapper.rb b/app/controllers/wrappers/policy_wrapper.rb index 3f90962157..402bf14871 100644 --- a/app/controllers/wrappers/policy_wrapper.rb +++ b/app/controllers/wrappers/policy_wrapper.rb @@ -9,9 +9,9 @@ module PolicyWrapper extend ActiveSupport::Concern include PolicyTemplates::TemplatesRenderer - def load_policy(loader_class, delete_permitted) + def load_policy(loader_class, delete_permitted, resource) begin - policy = save_submitted_policy(delete_permitted: delete_permitted) + policy = save_submitted_policy(delete_permitted: delete_permitted, resource: resource) loaded_policy = loader_class.from_policy(policy) created_roles = perform(loaded_policy) { created_roles: created_roles, policy: policy } @@ -20,10 +20,10 @@ def load_policy(loader_class, delete_permitted) end end - def submit_policy(policy_loader, policy_tamplate, input) + def submit_policy(policy_loader, policy_tamplate, input, resource) result_yaml = renderer(policy_tamplate, input) set_raw_policy(result_yaml) - result = load_policy(policy_loader, false) + result = load_policy(policy_loader, false, resource) result end @@ -35,7 +35,7 @@ def set_raw_policy(raw_policy) @raw_policy = raw_policy end - def save_submitted_policy(delete_permitted:) + def save_submitted_policy(delete_permitted:, resource:) policy_version = PolicyVersion.new( role: current_user, policy: resource, diff --git a/app/domain/policy-templates/grants/grant_host_safe.rb b/app/domain/policy-templates/grants/grant_host_safe.rb new file mode 100644 index 0000000000..d66344888c --- /dev/null +++ b/app/domain/policy-templates/grants/grant_host_safe.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class GrantHostSafe < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !grant + role: !group delegation/consumers + member: !host <%= id %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/hosts/create_host.rb b/app/domain/policy-templates/hosts/create_host.rb index 74940905c8..3f65116701 100644 --- a/app/domain/policy-templates/hosts/create_host.rb +++ b/app/domain/policy-templates/hosts/create_host.rb @@ -13,20 +13,6 @@ def template <%= key %>: <%= value %> <%- end -%> <% end %> - <% unless groups.nil? || groups.empty?%> - <% groups.each do |group| %> - - !grant - role: !group <%= group %> - member: !host <%= id %> - <% end %> - <% end %> - <% unless layers.nil? || layers.empty?%> - <% layers.each do |layer| %> - - !grant - role: !layer <%= layer %> - member: !host <%= id %> - <% end %> - <% end %> TEMPLATE end end diff --git a/spec/controllers/concerns/find_policy_resource_spec.rb b/spec/controllers/concerns/find_policy_resource_spec.rb deleted file mode 100644 index 1567d6026e..0000000000 --- a/spec/controllers/concerns/find_policy_resource_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -describe FindPolicyResource do - context "when resource cannot be found" do - let(:resource) { nil } - describe '#resource' do - it "raises an error" do - expect { controller.send(:resource) } - .to raise_error(Exceptions::RecordNotFound) - end - end - end - - before do - allow(Resource).to receive(:[]).with(resource_id).and_return(resource) - allow(controller).to receive(:resource_id) { resource_id } - end - - let(:resource_id) { 'test:policy:resource' } - - # Test controller class - class Controller - include FindPolicyResource - end - - subject(:controller) { Controller.new } -end diff --git a/spec/controllers/hosts_controller_spec.rb b/spec/controllers/hosts_controller_spec.rb index f5acb0f59c..0270dae165 100644 --- a/spec/controllers/hosts_controller_spec.rb +++ b/spec/controllers/hosts_controller_spec.rb @@ -35,39 +35,37 @@ let(:test_policy) do <<~POLICY - !user alice + - !policy + id: test - !policy id: dev body: - - !group developers - - !layer layers + - !policy + id: delegation + body: + - !group consumers - !variable secret1 - !variable secret2 - !variable secret3 - - - !grant - role: !group dev/developers - member: !user alice + - !permit resource: !policy dev - privilege: [ create ] + privilege: [ create, update ] role: !user alice - !permit - resource: !variable dev/secret1 - privileges: [ read, execute ] - roles: !group dev/developers + resource: !policy test + privilege: [ create, update ] + role: !user alice + - !permit - resource: !variable dev/secret2 + resource: !variable dev/secret1 privileges: [ read, execute ] - roles: !group dev/developers + roles: !group dev/delegation/consumers - - !permit - resource: !variable dev/secret3 - privileges: [ read, execute ] - roles: !group dev/developers POLICY end @@ -97,7 +95,7 @@ end end - context "when user send body with annotations, groups and layers" do + context "when user send body with annotations, safes" do let(:payload_create_hosts_annotations) do <<~BODY { @@ -105,17 +103,14 @@ "annotations": { "description": "describe" }, - "groups": [ - "developers" - ], - "layers": [ - "layers" + "safes": [ + "dev" ] } BODY end it 'returns created and can fetch secret' do - post("/hosts/rspec/dev", + post("/hosts/rspec/test", env: token_auth_header(role: alice_user).merge( { 'RAW_POST_DATA' => payload_create_hosts_annotations, @@ -124,7 +119,7 @@ ) ) assert_response :created - host_role = Role.find(role_id: "rspec:host:dev/new-host2") + host_role = Role.find(role_id: "rspec:host:test/new-host2") get("#{url_variable}/dev/secret1", env: token_auth_header(role: host_role)) expect(response.body).to include("#{test_value}") @@ -180,17 +175,16 @@ end end - context "Invalid values for groups and layers" do - let(:payload_invalid_group) do + + context "Wrong json object for safes" do + let(:payload_invalid_safe) do <<~BODY { "id": "new-host2", "annotations": { "description": "describe" }, - "groups": [ - "invalid" - ] + "safes": "invalid" } BODY end @@ -198,37 +192,39 @@ post("/hosts/rspec/dev", env: token_auth_header(role: alice_user).merge( { - 'RAW_POST_DATA' => payload_invalid_group, + 'RAW_POST_DATA' => payload_invalid_safe, 'CONTENT_TYPE' => "application/json" } ) ) - assert_response :not_found + assert_response :unprocessable_entity end - let(:payload_invalid_layer) do + + end + context "Wrong json object for safes" do + let(:payload_not_found_safe) do <<~BODY { "id": "new-host2", "annotations": { "description": "describe" }, - "layers": [ - "invalid" - ] + "safes": ["not-found"] } BODY end - it 'invalid layer value should return 400' do + it 'not exist value of safe return 404' do post("/hosts/rspec/dev", env: token_auth_header(role: alice_user).merge( { - 'RAW_POST_DATA' => payload_invalid_layer, + 'RAW_POST_DATA' => payload_not_found_safe, 'CONTENT_TYPE' => "application/json" } ) ) assert_response :not_found end + end end From b472a4d7ce8144bafa1e6f30e4c6c796e48944af Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Mon, 31 Jul 2023 16:26:40 +0300 Subject: [PATCH 069/665] Add the platform table creation DB migrate file --- db/migrate/20230706085100_create_platforms.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 db/migrate/20230706085100_create_platforms.rb diff --git a/db/migrate/20230706085100_create_platforms.rb b/db/migrate/20230706085100_create_platforms.rb new file mode 100644 index 0000000000..597391e7b6 --- /dev/null +++ b/db/migrate/20230706085100_create_platforms.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table :platforms do + String :platform_id + String :account + String :platform_type, null: false + Integer :max_ttl, null: false + column :data, "bytea" + Timestamp :created_at, null: false, default: Sequel.function(:transaction_timestamp) + Timestamp :modified_at, null: false + primary_key [:account, :platform_id], name: :platforms_pk + foreign_key :policy_id, :resources, type: String, null: false, on_delete: :cascade + end + end +end From 477287cf1bf7d3b08afb136979cb3066a44b819b Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Mon, 31 Jul 2023 16:29:06 +0300 Subject: [PATCH 070/665] Added create, delete, get and list REST APIs for Platforms --- .../concerns/find_platform_resource.rb | 22 ++ app/controllers/platforms_controller.rb | 221 ++++++++++++++++++ app/domain/logs.rb | 14 ++ .../platform_types/aws_platform_type.rb | 41 ++++ .../platform_types/platform_base_type.rb | 66 ++++++ .../platform_types/platform_type_factory.rb | 11 + .../platforms/create_platform.rb | 48 ++++ .../platforms/delete_platform.rb | 28 +++ app/models/audit/event/platform.rb | 100 ++++++++ app/models/audit/subject.rb | 6 + app/models/platform.rb | 21 ++ config/routes.rb | 5 + 12 files changed, 583 insertions(+) create mode 100644 app/controllers/concerns/find_platform_resource.rb create mode 100644 app/controllers/platforms_controller.rb create mode 100644 app/domain/platforms/platform_types/aws_platform_type.rb create mode 100644 app/domain/platforms/platform_types/platform_base_type.rb create mode 100644 app/domain/platforms/platform_types/platform_type_factory.rb create mode 100644 app/domain/policy-templates/platforms/create_platform.rb create mode 100644 app/domain/policy-templates/platforms/delete_platform.rb create mode 100644 app/models/audit/event/platform.rb create mode 100644 app/models/platform.rb diff --git a/app/controllers/concerns/find_platform_resource.rb b/app/controllers/concerns/find_platform_resource.rb new file mode 100644 index 0000000000..69a51751e8 --- /dev/null +++ b/app/controllers/concerns/find_platform_resource.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module FindPlatformResource + include FindResource + extend ActiveSupport::Concern + + def resource_id + if request.request_method == "GET" && request.filtered_parameters[:action] == "get" + [ account, "policy", "data/platforms/#{params[:identifier]}" ].join(":") + else + [ account, "policy", "data/platforms" ].join(":") + end + end + + def find_or_create_root_policy + Loader::Types.find_or_create_root_policy(account) + end + + def account + @account ||= params[:account] + end + +end \ No newline at end of file diff --git a/app/controllers/platforms_controller.rb b/app/controllers/platforms_controller.rb new file mode 100644 index 0000000000..6198979595 --- /dev/null +++ b/app/controllers/platforms_controller.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +require_relative '../controllers/wrappers/templates_renderer' +require_relative '../domain/platforms/platform_types/platform_type_factory' +# +class PlatformsController < RestController + include AuthorizeResource + include PolicyAudit + include PolicyWrapper + include PolicyTemplates::TemplatesRenderer + include BodyParser + include FindPlatformResource + + before_action :current_user + before_action :find_or_create_root_policy + + rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load + + PLATFORM_NOT_FOUND = "Platform not found" + + def create + logger.info(LogMessages::Endpoints::EndpointRequested.new("POST platforms/#{params[:account]}")) + action = :create + authorize(action, resource) + + platform_type = PlatformTypeFactory.new.create_platform_type(params[:type]) + platform_type.validate(params) + + policy_fields = input_create_yaml(params[:id], platform_type.default_secret_method) + create_platform_policy(policy_fields) + + save_platform(params) + platform = get_platform_from_db(params[:account], params[:id]) + raise ApplicationController::InternalServerError, "There was an error saving the platform" unless platform + platform_audit_success(platform.account, platform.platform_id, "create") + + render(json: platform.as_json, status: :created) + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("POST platforms/#{params[:account]}")) + rescue Exceptions::RecordNotFound => e + logger.error(LogMessages::Platforms::PlatformEndpointForbidden.new("create")) + audit_failure(e, action) + platform_audit_failure(params[:account], params[:id], "create", e.message) + raise Exceptions::Forbidden, "platforms" + rescue ApplicationController::BadRequest => e + logger.error("Input validation error for platform [#{params[:id]}]: #{e.message}") + audit_failure(e, action) + render(json: { + error: { + code: "bad_request", + message: e.message + } + }, status: :bad_request) + rescue Sequel::UniqueConstraintViolation => e + logger.error("Platform [#{params[:id]}] already exists") + audit_failure(e, action) + platform_audit_failure(params[:account], params[:id], "create", e.message) + raise Exceptions::RecordExists.new("platform", params[:id]) + rescue => e + audit_failure(e, action) + platform_audit_failure(params[:account], params[:id], "create", e.message) + head :internal_server_error + end + + def delete + logger.info(LogMessages::Endpoints::EndpointRequested.new("DELETE platforms/#{params[:account]}/#{params[:identifier]}")) + action = :update + authorize(action, resource) + + platform = get_platform_from_db(params[:account], params[:identifier]) + if platform + policy_fields = input_delete_yaml(params[:identifier]) + delete_platform_policy(policy_fields) + platform_audit_success(platform.account, platform.platform_id, "delete") + # Deleting the platform causes a cascade delete of the record in the platforms table as well + head :ok + else + raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) + end + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("DELETE platforms/#{params[:account]}/#{params[:identifier]}")) + rescue Exceptions::RecordNotFound => e + logger.error(LogMessages::Platforms::PlatformPolicyNotFound.new(resource_id)) + audit_failure(e, action) + platform_audit_failure(params[:account], params[:identifier], "delete", e.message) + raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) + rescue => e + audit_failure(e, action) + platform_audit_failure(params[:account], params[:identifier], "delete", e.message) + raise e + end + + def get + logger.info(LogMessages::Endpoints::EndpointRequested.new("GET platforms/#{params[:account]}/#{params[:identifier]}")) + # If I can update the platform policy, it means I am allowed to view it as well + action = :update + authorize(action, resource) + + platform = get_platform_from_db(params[:account], params[:identifier]) + if platform + platform_audit_success(platform.account, platform.platform_id, "get") + render(json: platform.as_json, status: :ok) + else + # platform_audit_failure(platform.account, platform.platform_id, "get", PLATFORM_NOT_FOUND) + raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) + end + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET platforms/#{params[:account]}/#{params[:identifier]}")) + rescue Exceptions::RecordNotFound => e + platform_audit_failure(params[:account], params[:identifier], "get", PLATFORM_NOT_FOUND) + logger.error(LogMessages::Platforms::PlatformPolicyNotFound.new(resource_id)) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET platforms/#{params[:account]}/#{params[:identifier]}")) + raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) + rescue => e + platform_audit_failure(params[:account], params[:identifier], "get", e.message) + raise e + end + + def list + logger.info(LogMessages::Endpoints::EndpointRequested.new("GET platforms/#{params[:account]}")) + # If I can update the platform policy, it means I am allowed to view it as well + action = :update + authorize(action, resource) + + platforms = list_platforms_from_db(params[:account]) + result = [] + platforms.each do |item| + result.push(item.as_json) + end + platform_audit_success(params[:account], "*", "list") + render(json: { platforms: result }, status: :ok) + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET platforms/#{params[:account]}")) + rescue Exceptions::RecordNotFound => e + logger.error(LogMessages::Platforms::PlatformEndpointForbidden.new("list")) + platform_audit_failure(params[:account], "*", "list", e.message) + raise Exceptions::Forbidden, "platforms" + rescue => e + platform_audit_failure(params[:account], "*", "list", e.message) + raise e + end +end + +private + +def create_platform_policy(policy_fields) + result_yaml = renderer(PolicyTemplates::CreatePlatform.new(), policy_fields) + set_raw_policy(result_yaml) + result = load_policy(Loader::CreatePolicy, false) + policy = result[:policy] + audit_success(policy) +end + +def delete_platform_policy(policy_fields) + result_yaml = renderer(PolicyTemplates::DeletePlatform.new(), policy_fields) + set_raw_policy(result_yaml) + result = load_policy(Loader::ModifyPolicy, true) + policy = result[:policy] + audit_success(policy) +end + +def save_platform(request_input) + platform = Platform.new(platform_id: request_input[:id], account: request_input[:account], + platform_type: request_input[:type], + max_ttl: request_input[:max_ttl], data: request_input[:data].to_json, + modified_at: Sequel::CURRENT_TIMESTAMP, + policy_id: "#{request_input[:account]}:policy:data/platforms/#{request_input[:id]}") + platform.save +end + +def get_platform_from_db(account, platform_id) + Platform.where(account: account, platform_id: platform_id).first +end + +def list_platforms_from_db(account) + Platform.where(account: account).all +end + +def input_create_yaml(platform_id, secret_method) + return input = { + "id" => platform_id, + "default_secret_method" => secret_method + } +end + +def input_delete_yaml(platform_id) + return input = { + "id" => platform_id + } +end + +def platform_audit_success(account, platform_id, operation) + subject = { account: account, platform: platform_id } + Audit.logger.log( + Audit::Event::Platform.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "platform", + success: true, + operation: operation + ) + ) +end + +def platform_audit_failure(account, platform_id, operation, error_message) + subject = { account: account, platform: platform_id } + Audit.logger.log( + Audit::Event::Platform.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "platform", + success: false, + operation: operation, + error_message: error_message + ) + ) +end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index fd32bb6f30..d0c86df6bb 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -780,6 +780,20 @@ module AuthnJwt ) end end + + module Platforms + + PlatformPolicyNotFound = ::Util::TrackableErrorClass.new( + msg: "The policy of platform {0} was not found", + code: "CONJ00158E" + ) + + PlatformEndpointForbidden = ::Util::TrackableErrorClass.new( + msg: "Action {0} is not allowed on the platforms endpoint", + code: "CONJ00159E" + ) + + end # This are log messages so its okay there are many # :reek:TooManyConstants diff --git a/app/domain/platforms/platform_types/aws_platform_type.rb b/app/domain/platforms/platform_types/aws_platform_type.rb new file mode 100644 index 0000000000..c5f836cb49 --- /dev/null +++ b/app/domain/platforms/platform_types/aws_platform_type.rb @@ -0,0 +1,41 @@ +require_relative './platform_base_type' + +class AwsPlatformType < PlatformBaseType + REQUIRED_DATA_PARAM_MISSING = "%s is a required parameter in data and must be specified".freeze + + def validate(params) + super + validate_data(params[:data]) + end + + def default_secret_method + "iam_session" + end +end + +private + +def validate_data(data) + unless data.is_a?(ActionController::Parameters) + raise ApplicationController::BadRequest, "data must be a valid JSON object" + end + + data_fields = { + access_key_id: "access_key_id", + access_key_secret: "access_key_secret" + } + + data_fields.each do |field_symbol, field_string| + if data[field_symbol].nil? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, field_string) + end + + unless data[field_symbol].is_a?(String) + raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, field_string, "string") + end + + if data[field_symbol].empty? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, field_string) + end + end +end \ No newline at end of file diff --git a/app/domain/platforms/platform_types/platform_base_type.rb b/app/domain/platforms/platform_types/platform_base_type.rb new file mode 100644 index 0000000000..fc0266bc72 --- /dev/null +++ b/app/domain/platforms/platform_types/platform_base_type.rb @@ -0,0 +1,66 @@ +class PlatformBaseType + REQUIRED_PARAM_MISSING = "%s is a required parameter and must be specified".freeze + WRONG_PARAM_TYPE = "%s param must be a %s".freeze + + ID_FIELD_ALLOWED_CHARACTERS = /\A[a-zA-Z0-9+\-_]+\z/ + ID_FIELD_MAX_ALLOWED_LENGTH = 60 + + def validate(params) + validate_id(params[:id]) + validate_max_ttl(params[:max_ttl]) + validate_type(params[:type]) + end + + def default_secret_method + raise NotImplementedError, "This method is not implemented because it's a base class" + end +end + +private + +def validate_id(id) + if id.nil? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "id") + end + + unless id.is_a?(String) + raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, "id", "string") + end + + if id.empty? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "id") + end + + unless id.match?(PlatformBaseType::ID_FIELD_ALLOWED_CHARACTERS) + raise ApplicationController::BadRequest, "id param only supports alpha numeric characters and +-_" + end + + if id.length > PlatformBaseType::ID_FIELD_MAX_ALLOWED_LENGTH + raise ApplicationController::BadRequest, "id param must be up to #{PlatformBaseType::ID_FIELD_MAX_ALLOWED_LENGTH} characters" + end +end + +def validate_max_ttl(max_ttl) + if max_ttl.nil? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "max_ttl") + end + + unless max_ttl.is_a?(Integer) && max_ttl.positive? + raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, "max_ttl", "positive integer") + end + +end + +def validate_type(type) + if type.nil? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "type") + end + + unless type.is_a?(String) + raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, "type", "string") + end + + if type.empty? + raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "type") + end +end diff --git a/app/domain/platforms/platform_types/platform_type_factory.rb b/app/domain/platforms/platform_types/platform_type_factory.rb new file mode 100644 index 0000000000..e560a40986 --- /dev/null +++ b/app/domain/platforms/platform_types/platform_type_factory.rb @@ -0,0 +1,11 @@ +require_relative './aws_platform_type' + +class PlatformTypeFactory + def create_platform_type(type) + if !type.nil? && type.casecmp("aws").zero? + AwsPlatformType.new + else + raise ApplicationController::BadRequest, "platform type must be aws" + end + end +end diff --git a/app/domain/policy-templates/platforms/create_platform.rb b/app/domain/policy-templates/platforms/create_platform.rb new file mode 100644 index 0000000000..50f8e56cdb --- /dev/null +++ b/app/domain/policy-templates/platforms/create_platform.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreatePlatform < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !policy + id: <%= id %> + body: + - !permit + role: !group delegation/secrets-creators + privileges: [ use ] + resource: !policy + + - !permit + role: !group delegation/secrets-creators + privileges: [ read, execute ] + resource: !variable secrets/default + + - !permit + role: !group delegation/secrets-creators + privileges: [ update ] + resource: !policy secrets + + - !permit + role: !group delegation/consumers + privileges: [ read, execute ] + resource: !variable secrets/default + + - !policy + id: delegation + body: + - !group secrets-creators + - !group consumers + + - !policy + id: secrets + body: + - !variable + id: default + annotations: + platform/id: <%= id %> + platform/method: <%= default_secret_method %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/platforms/delete_platform.rb b/app/domain/policy-templates/platforms/delete_platform.rb new file mode 100644 index 0000000000..f6205c77d4 --- /dev/null +++ b/app/domain/policy-templates/platforms/delete_platform.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class DeletePlatform < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !delete + record: !variable <%= id %>/secrets/default + + - !delete + record: !policy <%= id %>/secrets + + - !delete + record: !group <%= id %>/delegation/consumers + + - !delete + record: !group <%= id %>/delegation/secrets-creators + + - !delete + record: !policy <%= id %>/delegation + + - !delete + record: !policy <%= id %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/models/audit/event/platform.rb b/app/models/audit/event/platform.rb new file mode 100644 index 0000000000..a0112d81bb --- /dev/null +++ b/app/models/audit/event/platform.rb @@ -0,0 +1,100 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class Platform + + attr_reader :message_id + + def initialize( + user_id:, + client_ip:, + subject:, + message_id:, + success:, + operation:, + error_message: nil + ) + @user_id = user_id + @subject = subject + @client_ip = client_ip + @success = success + @operation = operation + @message_id=message_id + @error_message = error_message + + # Implements `==` for audit events + @comparable_evt = ComparableEvent.new(self) + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + # action_sd means "action structured data" + def action_sd + attempted_action.action_sd + end + + def severity + attempted_action.severity + end + + def to_s + message + end + + def message + if @operation == "list" + attempted_action.message( + success_msg: "#{@user_id} listed platforms #{@subject[:account]}:platform:#{@subject[:platform]}", + failure_msg: "#{@user_id} tried to list platforms #{@subject[:account]}:platform:#{@subject[:platform]}", + error_msg: @error_message + ) + else + attempted_action.message( + success_msg: "#{@user_id} performed #{@operation} on platform #{@subject[:account]}:platform:#{@subject[:platform]}", + failure_msg: "#{@user_id} tried to #{@operation} platform #{@subject[:account]}:platform:#{@subject[:platform]}", + error_msg: @error_message + ) + end + end + + def structured_data + { + SDID::AUTH => { user: @user_id }, + SDID::SUBJECT => @subject, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/master/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + def ==(other) + @comparable_evt == other + end + + private + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: @success, + operation: @operation + ) + end + end + end +end diff --git a/app/models/audit/subject.rb b/app/models/audit/subject.rb index 1c384d0de2..ca3a23328e 100644 --- a/app/models/audit/subject.rb +++ b/app/models/audit/subject.rb @@ -24,6 +24,12 @@ class Resource < Subject to_s { format "resource %s", resource_id } end + class Platform < Subject + field :platform_id + to_h {{ resource: platform_id }} + to_s { format "platform %s", platform_id } + end + class Role < Subject field :role_id to_h {{ role: role_id }} diff --git a/app/models/platform.rb b/app/models/platform.rb new file mode 100644 index 0000000000..f674074de9 --- /dev/null +++ b/app/models/platform.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'json' + +class Platform < Sequel::Model + + attr_encrypted :data, aad: :platform_id + + unrestrict_primary_key + + def as_json + { + id: self.platform_id, + max_ttl: self.max_ttl, + type: self.platform_type, + data: JSON.parse(self.data), + created_at: self.created_at, + modified_at: self.modified_at + } + end +end diff --git a/config/routes.rb b/config/routes.rb index 98aa91cb01..b5d379903a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,6 +88,11 @@ def matches?(request) post "/edge/data/:account" => 'edge#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' + post "/platforms/:account" => 'platforms#create' + delete "/platforms/:account/:identifier" => 'platforms#delete' + get "/platforms/:account/:identifier" => 'platforms#get' + get "/platforms/:account" => 'platforms#list' + put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' post "/policies/:account/:kind/*identifier" => 'policies#post' From dc26d476532d5cd21a3b7a5f595d78e14c78f2ff Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Mon, 31 Jul 2023 16:30:04 +0300 Subject: [PATCH 071/665] Added cucumber and rspec tests for the Platforms REST APIs --- cucumber/api/features/platforms.feature | 190 ++++++++ .../platform_types/aws_platform_type_spec.rb | 101 +++++ .../platform_types/platform_base_type_spec.rb | 95 ++++ .../platform_type_factory_spec.rb | 26 ++ .../concerns/find_platform_resource_spec.rb | 28 ++ spec/controllers/platforms_controller_spec.rb | 423 ++++++++++++++++++ 6 files changed, 863 insertions(+) create mode 100644 cucumber/api/features/platforms.feature create mode 100644 spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb create mode 100644 spec/app/domain/platforms/platform_types/platform_base_type_spec.rb create mode 100644 spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb create mode 100644 spec/controllers/concerns/find_platform_resource_spec.rb create mode 100644 spec/controllers/platforms_controller_spec.rb diff --git a/cucumber/api/features/platforms.feature b/cucumber/api/features/platforms.feature new file mode 100644 index 0000000000..73ec690f7e --- /dev/null +++ b/cucumber/api/features/platforms.feature @@ -0,0 +1,190 @@ +@api +@logged-in +Feature: Platforms audits tests + + Background: + Given I am the super-user + And I successfully POST "/policies/cucumber/policy/root" with body: + """ + - !policy + id: data/platforms + body: [] + """ + And I set the "Content-Type" header to "application/json" + And I successfully POST "/platforms/cucumber" with body: + """ + { + "id": "aws-platform-1", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "access_key_secret": "my-key-secret" + } + } + """ + + @smoke + Scenario: Successful audit when creating a platform + + Given I am the super-user + And I save my place in the audit log file for remote + When I set the "Content-Type" header to "application/json" + And I successfully POST "/platforms/cucumber" with body: + """ + { + "id": "aws-new-platform", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "access_key_secret": "my-key-secret" + } + } + """ + Then the HTTP response status code is 201 + And there is an audit record matching: + """ + <86>1 * - conjur * platform + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" platform="aws-new-platform"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="create"] + cucumber:user:admin performed create on platform cucumber:platform:aws-new-platform + """ + + @smoke + Scenario: Failure audit when creating a platform + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I set the "Content-Type" header to "application/json" + And I POST "/platforms/cucumber" with body: + """ + { + "id": "aws-new-platform", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "access_key_secret": "my-key-secret" + } + } + """ + Then the HTTP response status code is 403 + And there is an audit record matching: + """ + <84>1 * - conjur * platform + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" platform="aws-new-platform"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="create"] + cucumber:user:alice tried to create platform cucumber:platform:aws-new-platform: Policy 'data/platforms' not found in account 'cucumber' + """ + + @smoke + Scenario: Successful audit when getting a platform + + Given I am the super-user + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I successfully GET "/platforms/cucumber/aws-platform-1" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <86>1 * - conjur * platform + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" platform="aws-platform-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="get"] + cucumber:user:admin performed get on platform cucumber:platform:aws-platform-1 + """ + + @smoke + Scenario: Failure audit when getting a platform + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I GET "/platforms/cucumber/aws-platform-1" + Then the HTTP response status code is 404 + And there is an audit record matching: + """ + <84>1 * - conjur * platform + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" platform="aws-platform-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="get"] + cucumber:user:alice tried to get platform cucumber:platform:aws-platform-1: Platform not found + """ + + @smoke + Scenario: Successful audit when listing platforms + + Given I am the super-user + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I successfully GET "/platforms/cucumber" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <86>1 * - conjur * platform + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" platform="*"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="list"] + cucumber:user:admin listed platforms cucumber:platform:* + """ + + @smoke + Scenario: Failure audit when listing platforms + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I GET "/platforms/cucumber" + Then the HTTP response status code is 403 + And there is an audit record matching: + """ + <84>1 * - conjur * platform + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" platform="*"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="list"] + cucumber:user:alice tried to list platforms cucumber:platform:*: Policy 'data/platforms' not found in account 'cucumber' + """ + + @smoke + Scenario: Failure audit when deleting a platform + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I DELETE "/platforms/cucumber/aws-platform-1" + Then the HTTP response status code is 404 + And there is an audit record matching: + """ + <84>1 * - conjur * platform + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" platform="aws-platform-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"][action@43868 result="failure" operation="delete"] + cucumber:user:alice tried to delete platform cucumber:platform:aws-platform-1: Policy 'data/platforms' not found in account 'cucumber' + """ + + @smoke + Scenario: Successful audit when deleting a platform + + Given I am the super-user + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I successfully DELETE "/platforms/cucumber/aws-platform-1" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <86>1 * - conjur * platform + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" platform="aws-platform-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="delete"] + cucumber:user:admin performed delete on platform cucumber:platform:aws-platform-1 + """ \ No newline at end of file diff --git a/spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb b/spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb new file mode 100644 index 0000000000..d09f8c6e64 --- /dev/null +++ b/spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "AwsPlatformType input validation" do + context "when all input is valid" do + it "then the input validation succeeds" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: "a", + access_key_secret: "a" + }) + + expect { AwsPlatformType.new.validate(params) } + .to_not raise_error + end + end + + context "when key id is not given in the data field" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_secret: "a" + }) + expect { AwsPlatformType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when key secret is not given in the data field" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: "a" + }) + expect { AwsPlatformType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when key id is not a string" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: 1, + access_key_secret: "a" + }) + expect { AwsPlatformType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when key id is an empty string" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: "", + access_key_secret: "a" + }) + expect { AwsPlatformType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when key secret is not a string" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: "a", + access_key_secret: 1 + }) + expect { AwsPlatformType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when key secret is an empty string" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: "a", + access_key_secret: "" + }) + expect { AwsPlatformType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end +end diff --git a/spec/app/domain/platforms/platform_types/platform_base_type_spec.rb b/spec/app/domain/platforms/platform_types/platform_base_type_spec.rb new file mode 100644 index 0000000000..7f6bd30a95 --- /dev/null +++ b/spec/app/domain/platforms/platform_types/platform_base_type_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true +require 'spec_helper' + +class BaseTypeTest < PlatformBaseType + def validate(params) + super + end + + def default_secret_method + super + end +end + +describe "PlatformBaseType input validation" do + + context "when all base input is valid" do + it "then the input validation succeeds" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 2000, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to_not raise_error + end + end + + context "when max_ttl is a negative number" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: -2000, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when max_ttl is 0" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 0, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when max_ttl is a floating number" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-platform-1", + max_ttl: 4.5, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when id is a number" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: 1, + max_ttl: 2000, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when id is longer than 60 characters" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "a" * 61, + max_ttl: 2000, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when id has invalid character ^" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "a^", + max_ttl: 2000, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when id has invalid character of a space" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "a hf", + max_ttl: 2000, + type: "aws") + expect { BaseTypeTest.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end +end diff --git a/spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb b/spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb new file mode 100644 index 0000000000..d4b2a167ea --- /dev/null +++ b/spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "PlatformTypeFactory input validation" do + + context "when platform type is supported" do + it "then relevant platform type is being created" do + expect { PlatformTypeFactory.new.create_platform_type("aws") } + .to_not raise_error + end + end + + context "when platform type is supported but with upper case" do + it "then relevant platform type is being created" do + expect { PlatformTypeFactory.new.create_platform_type("AWS") } + .to_not raise_error + end + end + + context "when platform type is not supported" do + it "then the factory returns an error" do + expect { PlatformTypeFactory.new.create_platform_type("abc") } + .to raise_error(ApplicationController::BadRequest) + end + end +end diff --git a/spec/controllers/concerns/find_platform_resource_spec.rb b/spec/controllers/concerns/find_platform_resource_spec.rb new file mode 100644 index 0000000000..465d5862d3 --- /dev/null +++ b/spec/controllers/concerns/find_platform_resource_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe FindPlatformResource do + context "when resource cannot be found" do + let(:resource) { nil } + describe '#resource' do + it "raises an error" do + expect { controller.send(:resource) } + .to raise_error(Exceptions::RecordNotFound) + end + end + end + + before do + allow(Resource).to receive(:[]).with(resource_id).and_return(resource) + allow(controller).to receive(:resource_id) { resource_id } + end + + let(:resource_id) { 'test:policy:resource' } + + # Test controller class + class Controller + include FindPlatformResource + end + + subject(:controller) { Controller.new } +end diff --git a/spec/controllers/platforms_controller_spec.rb b/spec/controllers/platforms_controller_spec.rb new file mode 100644 index 0000000000..a387391d93 --- /dev/null +++ b/spec/controllers/platforms_controller_spec.rb @@ -0,0 +1,423 @@ +# frozen_string_literal: true + +require 'spec_helper' +DatabaseCleaner.strategy = :truncation + +describe PlatformsController, type: :request do + let(:url_resource) { "/resources/rspec" } + before do + init_slosilo_keys("rspec") + # Load the base data/platforms policies into Conjur + + put( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => data_platforms_policy + ) + ) + assert_response :success + + end + + let(:data_platforms_policy) do + <<~POLICY + - !policy + id: data/platforms + body: [] + POLICY + end + + let(:admin_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + let(:current_user) { Role.find_or_create(role_id: current_user_id) } + let(:current_user_id) { 'rspec:user:admin' } + let(:alice_user) { Role.find_or_create(role_id: alice_user_id) } + let(:alice_user_id) { 'rspec:user:alice' } + + describe "#create" do + context "when a user sends body with id only" do + let(:payload_create_platforms_only_id) do + <<~BODY + { "id": "new-platform" } + BODY + end + it 'returns bad request' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platforms_only_id, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + end + end + + context "when user sends body with id, max_ttl, type and data" do + let(:payload_create_platforms_complete_input) do + <<~BODY + { + "id": "aws-platform-1", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "access_key_secret": "my-key-secret" + } + } + BODY + end + it 'it returns created' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platforms_complete_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + parsed_body = JSON.parse(response.body) + expect(parsed_body["id"]).to eq("aws-platform-1") + expect(parsed_body["max_ttl"]).to eq(3000) + expect(parsed_body["type"]).to eq("aws") + expect(parsed_body["data"]["access_key_id"]).to eq("my-key-id") + expect(parsed_body["data"]["access_key_secret"]).to eq("my-key-secret") + expect(response.body).to include("\"created_at\"") + expect(response.body).to include("\"modified_at\"") + expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-1")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-1/delegation")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-1/delegation/consumers")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-1/delegation/secrets-creators")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-1/secrets")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/platforms/aws-platform-1/secrets/default")).not_to eq(nil) + end + end + + context "when user creates a policy with unsupported symbols in its name" do + let(:payload_create_platforms_symbols_input) do + <<~BODY + { + "id": "aws-platform-!@\#$%^*()[]", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "access_key_secret": "my-key-secret" + } + } + BODY + end + it 'it returns created' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platforms_symbols_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + parsed_body = JSON.parse(response.body) + expect(parsed_body["error"]["code"]).to eq("bad_request") + expect(parsed_body["error"]["message"]).to eq("id param only supports alpha numeric characters and +-_") + expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-!@#$%^*()[]")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-!@#$%^*()[]/delegation")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-!@#$%^*()[]/delegation/consumers")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-!@#$%^*()[]/delegation/secrets-creators")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-!@#$%^*()[]/secrets")).to eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/platforms/aws-platform-!@#$%^*()[]/secrets/default")).to eq(nil) + end + end + + context "when user sends an empty body" do + let(:payload_empty) do + <<~BODY + { + } + BODY + end + it 'it returns bad_request' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_empty, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + end + end + + context "when user sends an empty id" do + let(:payload_blank_id) do + <<~BODY + { + "id": "" + } + BODY + end + it "it returns bad_request" do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_blank_id, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + + end + end + + context "when user sends a valid creation request but without permissions" do + let(:payload_create_platforms_valid_input) do + <<~BODY + { + "id": "valid-platform", + "max_ttl": 1000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "access_key_secret": "my-key-secret" + } + } + BODY + end + it 'returns forbidden' do + post("/platforms/rspec", + env: token_auth_header(role: alice_user).merge( + 'RAW_POST_DATA' => payload_create_platforms_valid_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :forbidden + expect(response.body).to eq("") + end + end + end + + describe "#delete" do + context "when a user deletes a platform that does not exist" do + it 'it returns not found' do + delete("/platforms/rspec/non-existing-platform", + env: token_auth_header(role: admin_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"non-existing-platform\"}}}") + end + end + + context "when a user deletes an existing platform" do + let(:payload_create_platform) do + <<~BODY + { + "id": "my-new-aws-platform", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "aaa", + "access_key_secret": "a" + } + } + BODY + end + it 'it is deleted successfully' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platform, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + delete("/platforms/rspec/my-new-aws-platform", env: token_auth_header(role: admin_user)) + assert_response :success + expect(Resource.find(resource_id: "rspec:policy:data/platforms/my-new-aws-platform")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:data/platforms/my-new-aws-platform/delegation")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:data/platforms/my-new-aws-platform/delegation/consumers")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:data/platforms/my-new-aws-platform/delegation/secrets-creators")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:data/platforms/my-new-aws-platform/secrets")).to eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/platforms/my-new-aws-platform/secrets/default")).to eq(nil) + end + + context "when a user deletes a non existing platform without permissions" do + it 'it returns not found' do + delete("/platforms/rspec/non-existing-platform", env: token_auth_header(role: alice_user)) + assert_response :not_found + end + end + end + + context "when a user tries to delete a platform without the correct permissions" do + let(:payload_create_platform) do + <<~BODY + { + "id": "platform-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "access_key_secret": "a" + } + } + BODY + end + it 'it returns not found' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platform, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + delete("/platforms/rspec/platform-1", + env: token_auth_header(role: alice_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"platform-1\"}}}") + end + end + end + + describe "#get" do + context "when a user gets a platform that exists" do + let(:payload_create_platform) do + <<~BODY + { + "id": "platform-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "access_key_secret": "a" + } + } + BODY + end + it 'the platform is returned' do + + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platform, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + get("/platforms/rspec/platform-1", + env: token_auth_header(role: admin_user)) + assert_response :success + parsed_body = JSON.parse(response.body) + expect(parsed_body["id"]).to eq("platform-1") + expect(parsed_body["max_ttl"]).to eq(200) + expect(parsed_body["type"]).to eq("aws") + expect(parsed_body["data"]["access_key_id"]).to eq("a") + expect(parsed_body["data"]["access_key_secret"]).to eq("a") + expect(response.body).to include("\"created_at\"") + expect(response.body).to include("\"modified_at\"") + end + end + + context "when a user gets a platform that does not exist" do + it 'the response is not found' do + get("/platforms/rspec/non-existing-platform", + env: token_auth_header(role: admin_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"non-existing-platform\"}}}") + end + end + + context "when a user that does not have permissions on platforms, gets a platform" do + let(:payload_create_platform) do + <<~BODY + { + "id": "platform-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "access_key_secret": "a" + } + } + BODY + end + it 'the response is not found' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platform, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + get("/platforms/rspec/platform-1", + env: token_auth_header(role: alice_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"platform-1\"}}}") + end + end + end + + describe "#list" do + context "when a user lists the platforms" do + let(:payload_create_platform_1) do + <<~BODY + { + "id": "platform-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "access_key_secret": "a" + } + } + BODY + end + let(:payload_create_platform_2) do + <<~BODY + { + "id": "platform-2", + "max_ttl": 300, + "type": "aws", + "data": { + "access_key_id": "aaa", + "access_key_secret": "aaa" + } + } + BODY + end + it 'the platforms are returned' do + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platform_1, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + post("/platforms/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_platform_2, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + get("/platforms/rspec", + env: token_auth_header(role: admin_user)) + assert_response :success + parsed_body = JSON.parse(response.body) + expect(parsed_body["platforms"].length).to eq(2) + + expect(parsed_body["platforms"][0]["id"]).to eq("platform-1") + expect(parsed_body["platforms"][0]["max_ttl"]).to eq(200) + expect(parsed_body["platforms"][0]["type"]).to eq("aws") + expect(parsed_body["platforms"][0]["data"]["access_key_id"]).to eq("a") + expect(parsed_body["platforms"][0]["data"]["access_key_secret"]).to eq("a") + + expect(parsed_body["platforms"][1]["id"]).to eq("platform-2") + expect(parsed_body["platforms"][1]["max_ttl"]).to eq(300) + expect(parsed_body["platforms"][1]["type"]).to eq("aws") + expect(parsed_body["platforms"][1]["data"]["access_key_id"]).to eq("aaa") + expect(parsed_body["platforms"][1]["data"]["access_key_secret"]).to eq("aaa") + end + end + + context "when a user lists the platforms, and there are no platforms" do + it 'the response is an object with an empty array' do + get("/platforms/rspec", + env: token_auth_header(role: admin_user)) + assert_response :success + expect(response.body).to eq("{\"platforms\":[]}") + end + end + + context "when a user that does not have permissions to list platforms" do + it 'the response is forbidden' do + get("/platforms/rspec", + env: token_auth_header(role: alice_user)) + assert_response :forbidden + expect(response.body).to eq("") + end + end + end +end From 524dcd028c22947b954a8d2605a45937970a7440 Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Mon, 31 Jul 2023 16:30:30 +0300 Subject: [PATCH 072/665] Updated the CHANGELOG with the Platforms REST API feature --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 030937fb4d..52d83752e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.3-cloud] - 2023-07-30 +## [1.0.3-cloud] - 2023-07-31 ### Added - Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 - Endpoint for creating edge host https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41980 +- Added create, update, delete and list REST APIs for Platforms https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42284 ## [1.0.2-cloud] - 2023-07-20 ### Changed From c455f1c1da2edce81bb799abd4ed3199d465ee42 Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Mon, 31 Jul 2023 17:21:16 +0300 Subject: [PATCH 073/665] Fixed the usage of policy_load following an update that was rebased from the main branch --- app/controllers/platforms_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/platforms_controller.rb b/app/controllers/platforms_controller.rb index 6198979595..4494775bd2 100644 --- a/app/controllers/platforms_controller.rb +++ b/app/controllers/platforms_controller.rb @@ -148,7 +148,7 @@ def list def create_platform_policy(policy_fields) result_yaml = renderer(PolicyTemplates::CreatePlatform.new(), policy_fields) set_raw_policy(result_yaml) - result = load_policy(Loader::CreatePolicy, false) + result = load_policy(Loader::CreatePolicy, false, resource) policy = result[:policy] audit_success(policy) end @@ -156,7 +156,7 @@ def create_platform_policy(policy_fields) def delete_platform_policy(policy_fields) result_yaml = renderer(PolicyTemplates::DeletePlatform.new(), policy_fields) set_raw_policy(result_yaml) - result = load_policy(Loader::ModifyPolicy, true) + result = load_policy(Loader::ModifyPolicy, true, resource) policy = result[:policy] audit_success(policy) end From 39f7c741dff63983973706c4b6cd0502d4058fcd Mon Sep 17 00:00:00 2001 From: ygeva Date: Tue, 1 Aug 2023 13:40:14 +0300 Subject: [PATCH 074/665] Create-workload-endpoint: support duplication conflict if there is duplicate host --- app/controllers/hosts_controller.rb | 13 ++++++++ spec/controllers/hosts_controller_spec.rb | 38 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index 368048f298..43b273eebb 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -21,6 +21,11 @@ def post .to_h.symbolize_keys authorize(action, resource(params[:identifier])) validateId(params[:id]) + hostId = "#{params[:account]}:host:#{build_host_name_without_slash(params)}" + hostResource = Resource.find(resource_id: hostId) + if !hostResource.nil? + raise Exceptions::RecordExists.new("host", hostId) + end input = input_host_create(params) result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input, resource(params[:identifier])) hostPolicy = result[:policy] @@ -67,6 +72,14 @@ def build_host_name(params) "/" + path.join('/') end + +def build_host_name_without_slash(params) + path = [] + path << params[:identifier] unless params[:identifier] == "root" + path << params[:id] + path.join('/') +end + def grantHostToSafes(params) safes = params[:safes] policies = [] diff --git a/spec/controllers/hosts_controller_spec.rb b/spec/controllers/hosts_controller_spec.rb index 0270dae165..2008e987aa 100644 --- a/spec/controllers/hosts_controller_spec.rb +++ b/spec/controllers/hosts_controller_spec.rb @@ -137,6 +137,44 @@ end end + context "duplicate will send conflict" do + let(:payload_create_hosts_annotations) do + <<~BODY + { + "id": "new-host2", + "annotations": { + "description": "describe" + }, + "safes": [ + "dev" + ] + } + BODY + end + it 'returns created and than conflict' do + + post("/hosts/rspec/test", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_create_hosts_annotations, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :created + post("/hosts/rspec/test", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_create_hosts_annotations, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :conflict + end + + end + context "empty id or body" do let(:payload_empty) do <<~BODY From 0038435a038b75b04749aae4088289d03f1b5dd8 Mon Sep 17 00:00:00 2001 From: egvili Date: Wed, 26 Jul 2023 15:22:13 +0300 Subject: [PATCH 075/665] Single Edge to multi --- app/db/preview/single_edge_to_multi.rb | 2 +- bin/conjur-cli/commands/db/migrate.rb | 1 + lib/tasks/single-edge.rake | 11 +++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 lib/tasks/single-edge.rake diff --git a/app/db/preview/single_edge_to_multi.rb b/app/db/preview/single_edge_to_multi.rb index 03ecb0d1fc..f418ac0b9e 100644 --- a/app/db/preview/single_edge_to_multi.rb +++ b/app/db/preview/single_edge_to_multi.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true - +#TODO: delete once single edge users are migrated to multi module DB module Preview class SingleEdgeToMulti diff --git a/bin/conjur-cli/commands/db/migrate.rb b/bin/conjur-cli/commands/db/migrate.rb index e069e07eb4..f25114dcc3 100644 --- a/bin/conjur-cli/commands/db/migrate.rb +++ b/bin/conjur-cli/commands/db/migrate.rb @@ -23,6 +23,7 @@ def call system("rake db:migrate-preview") || exit(($CHILD_STATUS.exitstatus)) else system("rake db:migrate") || exit(($CHILD_STATUS.exitstatus)) + system("rake db:migrate:status && rake db:single-to-multi") #TODO: delete once single edge users are migrated to multi end end end diff --git a/lib/tasks/single-edge.rake b/lib/tasks/single-edge.rake new file mode 100644 index 0000000000..444c0dc64e --- /dev/null +++ b/lib/tasks/single-edge.rake @@ -0,0 +1,11 @@ +require_relative '../../app/db/preview/single_edge_to_multi' +#TODO: delete once single edge users are migrated to multi +namespace :db do + desc "Migrate single edge to multi" + task :"single-to-multi", [] => [:environment] do |t, args| + single_host_id = ::DB::Preview::SingleEdgeToMulti.new.find_single_host_id + if single_host_id + Edge.dataset.insert_conflict(target: [:name]).insert({name: "Edge_01", id: single_host_id, version: "1.0.2", platform: "Podman"}) + end + end +end \ No newline at end of file From d5695ddd2b95e656f8792c631ec43720ab9cb39d Mon Sep 17 00:00:00 2001 From: egvili Date: Mon, 7 Aug 2023 17:32:34 +0300 Subject: [PATCH 076/665] Fix single edge migration --- bin/conjur-cli/commands/db/migrate.rb | 1 - bin/conjur-cli/commands/server.rb | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/conjur-cli/commands/db/migrate.rb b/bin/conjur-cli/commands/db/migrate.rb index f25114dcc3..e069e07eb4 100644 --- a/bin/conjur-cli/commands/db/migrate.rb +++ b/bin/conjur-cli/commands/db/migrate.rb @@ -23,7 +23,6 @@ def call system("rake db:migrate-preview") || exit(($CHILD_STATUS.exitstatus)) else system("rake db:migrate") || exit(($CHILD_STATUS.exitstatus)) - system("rake db:migrate:status && rake db:single-to-multi") #TODO: delete once single edge users are migrated to multi end end end diff --git a/bin/conjur-cli/commands/server.rb b/bin/conjur-cli/commands/server.rb index 2716d0ea47..02ac8a6e61 100644 --- a/bin/conjur-cli/commands/server.rb +++ b/bin/conjur-cli/commands/server.rb @@ -42,6 +42,8 @@ def call #fork_authn_local_process fork_rotation_process + system("rake db:single-to-multi") #TODO: delete once single edge users are migrated to multi + # Block until all child processes end wait_for_child_processes end From d1ce99e38bff91804abca2a2b4b650ffcccc24e5 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 7 Aug 2023 14:28:33 +0300 Subject: [PATCH 077/665] ONYX-42990- change edge create endpoint --- app/controllers/edge_controller.rb | 4 +- app/controllers/edge_creator_controller.rb | 12 ++- config/routes.rb | 4 +- cucumber/api/features/edge.feature | 4 +- cucumber/api/features/edge_create.feature | 94 ++++++++++++++++--- .../step_definitions/edge_create_step.rb | 2 +- spec/controllers/edge_controller_spec.rb | 2 +- 7 files changed, 98 insertions(+), 24 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index c2d89d9e2d..24dd7ca562 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -196,11 +196,11 @@ def generate_install_token end def all_edges - logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/edges")) + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge")) allowed_params = %i[account] options = params.permit(*allowed_params).to_h.symbolize_keys validate_conjur_admin_group(options[:account]) - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edges")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge")) render(json: Edge.order(:name).all.map{|edge| {name: edge.name, ip: edge.ip, last_sync: edge.last_sync.to_i, version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) diff --git a/app/controllers/edge_creator_controller.rb b/app/controllers/edge_creator_controller.rb index 1143689646..f2a7923f00 100644 --- a/app/controllers/edge_creator_controller.rb +++ b/app/controllers/edge_creator_controller.rb @@ -12,10 +12,11 @@ class EdgeCreatorController < RestController #this endpoint loads a policy with the edge host values + adds the edge name to Edge table def create_edge - logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/create')) + logger.info(LogMessages::Endpoints::EndpointRequested.new('create edge')) allowed_params = %i[account edge_name] url_params = params.permit(*allowed_params) validate_conjur_admin_group(url_params[:account]) + validate_name(url_params[:edge_name]) params[:identifier] = "edge" edge_name = params[:edge_name] @@ -30,7 +31,7 @@ def create_edge ensure created_audit(edge_name) end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/create")) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("create edge")) head :created end @@ -60,4 +61,11 @@ def created_audit(edge_name = "not-found") **audit_params )) end + + def validate_name(name) + if name.nil? || name.empty? + raise ApplicationController::UnprocessableEntity, "edge_name param is missing in body, must not be blank." + end + end + end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index b5d379903a..33049221dd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,11 +78,11 @@ def matches?(request) get "/secrets/:account/:kind/*identifier" => 'secrets#show' post "/secrets/:account/:kind/*identifier" => 'secrets#create' get "/secrets" => 'secrets#batch' - post "/edge/create/:account/:edge_name" => 'edge_creator#create_edge' + post "/edge/:account" => 'edge_creator#create_edge' get "/edge/secrets/:account" => 'edge#all_secrets' get "/edge/hosts/:account" => 'edge#all_hosts' get "edge/edge-creds/:account/:edge_name" => 'edge#generate_install_token' - get "/edge/edges/:account" => 'edge#all_edges' + get "/edge/:account" => 'edge#all_edges' get "/edge/max-allowed/:account" => 'edge#max_edges_allowed' post "/edge/data/:account" => 'edge#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 3096e126cb..b9a9ca0f5b 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -671,10 +671,10 @@ Feature: Fetching secrets from edge endpoint @negative @acceptance Scenario: List edges permitted only to admins Given I login as "admin_user" - When I GET "/edge/edges/cucumber" + When I GET "/edge/cucumber" Then the HTTP response status code is 200 Given I login as "some_user" - When I GET "/edge/edges/cucumber" + When I GET "/edge/cucumber" Then the HTTP response status code is 403 ###################### diff --git a/cucumber/api/features/edge_create.feature b/cucumber/api/features/edge_create.feature index 5f166ab4d6..f573f8aea5 100644 --- a/cucumber/api/features/edge_create.feature +++ b/cucumber/api/features/edge_create.feature @@ -34,7 +34,13 @@ Feature: Create edge process Scenario: Create edge host return 201 OK Given I login as "admin_user" And I save my place in the audit log file for remote - When I POST "/edge/create/cucumber/edgy" + And I set the "Content-Type" header to "application/json" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ Then the HTTP response status code is 201 And Edge name "edgy" data exists in db And there is an audit record matching: @@ -51,9 +57,20 @@ Feature: Create edge process Scenario: Create edge with existing name return 409 Given I login as "admin_user" And I save my place in the audit log file for remote - When I POST "/edge/create/cucumber/edgy" + And I set the "Content-Type" header to "application/json" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ Then the HTTP response status code is 201 - When I POST "/edge/create/cucumber/edgy" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ Then the HTTP response status code is 409 And there is an audit record matching: """ @@ -67,26 +84,59 @@ Feature: Create edge process @negative @acceptance Scenario: Create edge with non admin_user return 403 Given I login as "host/data/some_host1" - When I POST "/edge/create/cucumber/edgy1" + And I set the "Content-Type" header to "application/json" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy1" + } + """ Then the HTTP response status code is 403 @negative @acceptance Scenario: Exceeding max edges allowed return 422 Given I login as "admin_user" - When I POST "/edge/create/cucumber/edgy1" + And I set the "Content-Type" header to "application/json" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy1" + } + """ Then the HTTP response status code is 201 - When I POST "/edge/create/cucumber/edgy2" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy2" + } + """ Then the HTTP response status code is 201 - When I POST "/edge/create/cucumber/edgy3" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy3" + } + """ Then the HTTP response status code is 201 - When I POST "/edge/create/cucumber/edgy4" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy4" + } + """ Then the HTTP response status code is 422 @acceptance Scenario: Script generation success emits audit Given I login as "admin_user" And I save my place in the audit log file for remote - When I POST "/edge/create/cucumber/edgy" + And I set the "Content-Type" header to "application/json" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ Then the HTTP response status code is 201 When I GET "/edge/edge-creds/cucumber/edgy" Then the HTTP response status code is 200 @@ -103,7 +153,13 @@ Feature: Create edge process @negative Scenario: Script generation failure emits audit Given I login as "admin_user" - When I POST "/edge/create/cucumber/edgy" + And I set the "Content-Type" header to "application/json" + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ Then the HTTP response status code is 201 When I log out And I login as "some_user" @@ -123,11 +179,16 @@ Feature: Create edge process @acceptance Scenario: Edge start report success emits audit Given I login as "admin_user" - And I POST "/edge/create/cucumber/edgy" + And I set the "Content-Type" header to "application/json" + And I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ And the HTTP response status code is 201 And I save my place in the audit log file for remote When I login as the host associated with Edge "edgy" - And I set the "Content-Type" header to "application/json" And I POST "/edge/data/cucumber?data_type=install" with body: """ { "installation_date" : 1111111 } @@ -146,11 +207,16 @@ Feature: Create edge process @negative Scenario: Edge start report failure emits audit Given I login as "admin_user" - And I POST "/edge/create/cucumber/edgy" + And I set the "Content-Type" header to "application/json" + And I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ And the HTTP response status code is 201 And I save my place in the audit log file for remote When I login as the host associated with Edge "edgy" - And I set the "Content-Type" header to "application/json" And I POST "/edge/data/cucumber?data_type=install" with body: """ { "installation_bad_date" : 1111111 } diff --git a/cucumber/api/features/step_definitions/edge_create_step.rb b/cucumber/api/features/step_definitions/edge_create_step.rb index 0a80cde458..311acd3467 100644 --- a/cucumber/api/features/step_definitions/edge_create_step.rb +++ b/cucumber/api/features/step_definitions/edge_create_step.rb @@ -8,7 +8,7 @@ expect(res).to be > 0 res = Resource.where(Sequel.like(:resource_id, "cucumber:host:edge/edge-#{id}/edge-host-#{id}")).count expect(res).to be > 0 - res = Resource.where(Sequel.like(:resource_id, "cucumber:host:edge/edge-installer-#{id}/edge-installer-host-#{id}")) + res = Resource.where(Sequel.like(:resource_id, "cucumber:host:edge/edge-installer-#{id}/edge-installer-host-#{id}")).count expect(res).to be > 0 end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index a817375f89..8bb3a98a0f 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -27,7 +27,7 @@ end let(:list_edges) do - "/edge/edges/#{account}" + "/edge/#{account}" end let(:report_edge) do From 90150f3c6c72ea2b755ceb632969a10617c4f42c Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 7 Aug 2023 14:28:33 +0300 Subject: [PATCH 078/665] ONYX-42990- change edge create endpoint --- cucumber/api/features/edge_create.feature | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cucumber/api/features/edge_create.feature b/cucumber/api/features/edge_create.feature index f573f8aea5..d5e1017dfd 100644 --- a/cucumber/api/features/edge_create.feature +++ b/cucumber/api/features/edge_create.feature @@ -138,6 +138,7 @@ Feature: Create edge process } """ Then the HTTP response status code is 201 + And I set the "Content-Type" header to "text\\plain" When I GET "/edge/edge-creds/cucumber/edgy" Then the HTTP response status code is 200 And there is an audit record matching: @@ -164,6 +165,7 @@ Feature: Create edge process When I log out And I login as "some_user" And I save my place in the audit log file for remote + And I set the "Content-Type" header to "text\\plain" When I GET "/edge/edge-creds/cucumber/edgy" Then the HTTP response status code is 403 And there is an audit record matching: From 93dc7d3cdf3b1553ce7be416788ba494fe42a95f Mon Sep 17 00:00:00 2001 From: egvili Date: Wed, 9 Aug 2023 10:38:11 +0300 Subject: [PATCH 079/665] Harden edge input validations --- app/controllers/concerns/params_validator.rb | 13 +++++++- app/controllers/edge_creator_controller.rb | 12 ++++--- .../data_handlers/install_handler.rb | 0 .../data_handlers/ongoing_handler.rb | 20 ++++++++++-- .../data_handlers/ongoing_handler_spec.rb | 32 +++++++++++++++++++ spec/controllers/edge_controller_spec.rb | 18 +++++------ .../edge_creator_controller_spec.rb | 20 ++++++++++++ 7 files changed, 98 insertions(+), 17 deletions(-) rename app/{models => domain}/edge_logic/data_handlers/install_handler.rb (100%) rename app/{models => domain}/edge_logic/data_handlers/ongoing_handler.rb (62%) create mode 100644 spec/app/domain/edge_logic/data_handlers/ongoing_handler_spec.rb create mode 100644 spec/controllers/edge_creator_controller_spec.rb diff --git a/app/controllers/concerns/params_validator.rb b/app/controllers/concerns/params_validator.rb index 8ace1a7a15..0a4008eb0d 100644 --- a/app/controllers/concerns/params_validator.rb +++ b/app/controllers/concerns/params_validator.rb @@ -10,8 +10,19 @@ def validate_params(params, validator) if value.is_a?(Hash) # value is an item in a nested json from body validate_params(value, validator) else - raise ApplicationController::BadRequest unless validator.call(key, value) + unless validator.call(key, value) + raise ApplicationController::UnprocessableEntity, "Value provided for parameter #{key} is invalid" + end end end end + + def numeric_validator + @numeric_validator ||= ->(k, v){ v.is_a?(Numeric)} + end + + def string_length_validator + @string_length_validator ||= ->(k, v){(v.is_a?(String) && v.length <= 20)} + end + end diff --git a/app/controllers/edge_creator_controller.rb b/app/controllers/edge_creator_controller.rb index f2a7923f00..8d3c9b6426 100644 --- a/app/controllers/edge_creator_controller.rb +++ b/app/controllers/edge_creator_controller.rb @@ -9,6 +9,7 @@ class EdgeCreatorController < RestController include FindEdgePolicyResource include GroupMembershipValidator include PolicyWrapper + include ParamsValidator #this endpoint loads a policy with the edge host values + adds the edge name to Edge table def create_edge @@ -16,9 +17,9 @@ def create_edge allowed_params = %i[account edge_name] url_params = params.permit(*allowed_params) validate_conjur_admin_group(url_params[:account]) - validate_name(url_params[:edge_name]) + edge_name = url_params[:edge_name] + validate_name(edge_name) params[:identifier] = "edge" - edge_name = params[:edge_name] begin validate_max_edge_allowed(url_params[:account]) @@ -63,9 +64,10 @@ def created_audit(edge_name = "not-found") end def validate_name(name) - if name.nil? || name.empty? - raise ApplicationController::UnprocessableEntity, "edge_name param is missing in body, must not be blank." - end + validate_params({"edge_name" => name}, ->(k,v){ + !v.nil? && !v.empty? && + v.match?(/^[a-zA-Z0-9_]+$/) && string_length_validator.call(k, v) + }) end end \ No newline at end of file diff --git a/app/models/edge_logic/data_handlers/install_handler.rb b/app/domain/edge_logic/data_handlers/install_handler.rb similarity index 100% rename from app/models/edge_logic/data_handlers/install_handler.rb rename to app/domain/edge_logic/data_handlers/install_handler.rb diff --git a/app/models/edge_logic/data_handlers/ongoing_handler.rb b/app/domain/edge_logic/data_handlers/ongoing_handler.rb similarity index 62% rename from app/models/edge_logic/data_handlers/ongoing_handler.rb rename to app/domain/edge_logic/data_handlers/ongoing_handler.rb index 56968e1805..eb803cc70f 100644 --- a/app/models/edge_logic/data_handlers/ongoing_handler.rb +++ b/app/domain/edge_logic/data_handlers/ongoing_handler.rb @@ -11,10 +11,10 @@ def initialize(logger) def call(params, hostname, ip) params.require(:edge_statistics).require(:last_synch_time) - allowed_params = [:account, :edge_version, :edge_container_type, edge_statistics: [:last_synch_time, cycle_requests: + allowed_params = [:edge_version, :edge_container_type, edge_statistics: [:last_synch_time, cycle_requests: [:get_secret, :apikey_authenticate, :jwt_authenticate, :redirect]]] options = params.permit(*allowed_params).to_h - validate_params(options, ->(k, v) {v.is_a?(Numeric) || (v.is_a?(String) && v.length <= 20)}) + validate_params(options, input_validator) edge = Edge.get_by_hostname(hostname) edge.record_edge_access(options, ip) @@ -26,6 +26,22 @@ def call(params, hostname, ip) cycle_reqs['jwt_authenticate'], cycle_reqs['redirect'], edge.version, edge.platform, Time.at(edge.installation_date))) end + + def input_validator + @input_validator ||= ->(k, v) { + case k.to_sym + when :edge_version + v.match?(/^[0-9.v]+$/) + when :edge_container_type + %w[podman docker].include?(v.downcase) + when :last_synch_time, :cycle_requests, :get_secret, :apikey_authenticate, :jwt_authenticate, :redirect + numeric_validator.call(k, v) + else + numeric_validator.call(k, v) || string_length_validator.call(k, v) + end + } + end + end end end diff --git a/spec/app/domain/edge_logic/data_handlers/ongoing_handler_spec.rb b/spec/app/domain/edge_logic/data_handlers/ongoing_handler_spec.rb new file mode 100644 index 0000000000..227951c9b0 --- /dev/null +++ b/spec/app/domain/edge_logic/data_handlers/ongoing_handler_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "Ongoing handler" do + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + subject { EdgeLogic::DataHandlers::OngoingHandler.new(logger) } + + context "Input validation" do + it "edge version" do + expect(subject.input_validator.call("edge_version", "v1.0.0")).to eq(true) + expect(subject.input_validator.call("edge_version", "1.0.0")).to eq(true) + + expect(subject.input_validator.call("edge_version", "bad_version")).to eq(false) + end + + it "edge container type" do + expect(subject.input_validator.call("edge_container_type", "Podman")).to eq(true) + expect(subject.input_validator.call("edge_container_type", "podman")).to eq(true) + expect(subject.input_validator.call("edge_container_type", "Docker")).to eq(true) + + expect(subject.input_validator.call("edge_container_type", "shocker")).to eq(false) + end + + it "numeric params" do + expect(subject.input_validator.call("last_synch_time", 456432)).to eq(true) + + expect(subject.input_validator.call("last_synch_time", "Four")).to eq(false) + end + end +end + diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index 8bb3a98a0f..df173c471e 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -121,7 +121,7 @@ def send_request_with_correct_role before do Role.create(role_id: "#{account}:group:edge/edge-hosts") RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - Edge.new_edge(name: "edgy", id: 1234, version: "latest", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) end @@ -145,7 +145,7 @@ def send_request_with_correct_role before do Role.create(role_id: "#{account}:group:edge/edge-hosts") RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - Edge.new_edge(name: "edgy", id: 1234, version: "latest", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) EdgeController.logger = logger Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) @@ -165,7 +165,7 @@ def send_request_with_correct_role it "Report ongoing data endpoint works" do edge_details = '{"edge_statistics": {"last_synch_time": 222222222, "cycle_requests": { "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, - "edge_version": "latest", "edge_container_type": "podman"}' + "edge_version": "1.1.1", "edge_container_type": "podman"}' post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': edge_details}) .merge({'CONTENT_TYPE': 'application/json'})) @@ -173,7 +173,7 @@ def send_request_with_correct_role expect(response.code).to eq("204") db_edgy = Edge.where(name: "edgy").first expect(db_edgy.last_sync.to_i).to eq(222222222) - expect(db_edgy.version).to eq("latest") + expect(db_edgy.version).to eq("1.1.1") expect(db_edgy.platform).to eq("podman") output = log_output.string expect(output).to include("EdgeTelemetry") @@ -193,7 +193,7 @@ def send_request_with_correct_role expect(resp.size).to eq(4) expect(resp[0]['name']).to eq('edgy') expect(resp[0]['last_sync']).to eq(222222222) - expect(resp[0]['version']).to eq("latest") + expect(resp[0]['version']).to eq("1.1.1") expect(resp[0]['platform']).to eq("podman") expect(resp[1]['name']).to eq('fudge') @@ -202,7 +202,7 @@ def send_request_with_correct_role end it "Reported data appears on list" do - edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "latest", "edge_container_type": "podman"}' + edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': edge_details}) .merge({'CONTENT_TYPE': 'application/json'})) @@ -213,18 +213,18 @@ def send_request_with_correct_role resp = JSON.parse(response.body) expect(resp.size).to eq(1) expect(resp[0]['last_sync']).to eq(222222222) - expect(resp[0]['version']).to eq("latest") + expect(resp[0]['version']).to eq("1.1.1") expect(resp[0]['platform']).to eq("podman") end it "Report invalid data" do - missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "latest"}' + missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1"}' post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': missing_optional}) .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("204") - missing_required = '{"edge_statistics": {}, "edge_version": "latest", "edge_container_type": "podman"}' + missing_required = '{"edge_statistics": {}, "edge_version": "1.1.1", "edge_container_type": "podman"}' post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': missing_required}) .merge({'CONTENT_TYPE': 'application/json'})) diff --git a/spec/controllers/edge_creator_controller_spec.rb b/spec/controllers/edge_creator_controller_spec.rb new file mode 100644 index 0000000000..5122ecf144 --- /dev/null +++ b/spec/controllers/edge_creator_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe EdgeCreatorController, :type => :request do + + context "Edge name validation" do + subject{ EdgeCreatorController.new } + + it "Edge names are validated" do + expect { subject.send(:validate_name, "Edgy") }.to_not raise_error + expect { subject.send(:validate_name, "Edgy_05") }.to_not raise_error + + expect { subject.send(:validate_name, nil) }.to raise_error + expect { subject.send(:validate_name, "") }.to raise_error + expect { subject.send(:validate_name, "Edgy!") }.to raise_error + expect { subject.send(:validate_name, "SuperExtremelyLongEdgeName") }.to raise_error + end + end +end + From fd5c43bca1a8b68d737767091bdac67e8783c754 Mon Sep 17 00:00:00 2001 From: egvili Date: Wed, 9 Aug 2023 13:41:39 +0300 Subject: [PATCH 080/665] Support plural syntax for revoke and deny (cherry picked from commit 4b724b56e20101614c949a946b1ed3d0ad0a426d) --- CHANGELOG.md | 5 + app/models/loader/types.rb | 18 +++- cucumber/policy/features/deletion.feature | 118 +++++++++++++++++++++- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d83752e3..7760b48278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.4-cloud] - 2023-08-10 +### Fixed +- Support plural syntax for revoke and deny + [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) + ## [1.0.3-cloud] - 2023-07-31 ### Added - Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index d86658c0ac..07ae694492 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -354,15 +354,25 @@ class Deletion < Types::Base class Deny < Deletion def delete! - permission = ::Permission[role_id: policy_object.role.roleid, privilege: policy_object.privilege, resource_id: policy_object.resource.resourceid, policy_id: policy_id] - permission.destroy if permission + Array(policy_object.resource).each do |r| + Array(policy_object.privilege).each do |p| + Array(policy_object.role).each do |m| + permission = ::Permission[role_id: m.roleid, privilege: p, resource_id: r.resourceid, policy_id: policy_id] + permission.destroy if permission + end + end + end end end class Revoke < Deletion def delete! - membership = ::RoleMembership[role_id: policy_object.role.roleid, member_id: policy_object.member.roleid, policy_id: policy_id] - membership.destroy if membership + Array(policy_object.role).each do |r| + Array(policy_object.member).each do |m| + membership = ::RoleMembership[role_id: r.roleid, member_id: m.roleid, policy_id: policy_id] + membership.destroy if membership + end + end end end diff --git a/cucumber/policy/features/deletion.feature b/cucumber/policy/features/deletion.feature index 23b7a9c7b6..02020a5858 100644 --- a/cucumber/policy/features/deletion.feature +++ b/cucumber/policy/features/deletion.feature @@ -127,7 +127,62 @@ Feature: Deleting objects and relationships. Then group "developers" is not a role member @smoke - Scenario: The !deny statement can be used to revoke a permission. + Scenario: The bulk !revoke statement can be used to revoke multiple roles and members. + Given I load a policy: + """ + - !group developers1 + - !group developers2 + - !group developers3 + - !group employees1 + - !group employees2 + - !group employees3 + - !grant + roles: + - !group employees1 + - !group employees2 + - !group employees3 + members: + - !group developers1 + - !group developers2 + - !group developers3 + """ + When I show the group "employees1" + Then group "developers1" is a role member + And group "developers2" is a role member + And group "developers3" is a role member + When I show the group "employees2" + Then group "developers1" is a role member + And group "developers2" is a role member + And group "developers3" is a role member + When I show the group "employees3" + Then group "developers1" is a role member + And group "developers2" is a role member + And group "developers3" is a role member + When I update the policy with: + """ + - !revoke + roles: + - !group employees1 + - !group employees2 + members: + - !group developers1 + - !group developers2 + """ + And I show the group "employees1" + Then group "developers1" is not a role member + And group "developers2" is not a role member + And group "developers3" is a role member + When I show the group "employees2" + Then group "developers1" is not a role member + And group "developers2" is not a role member + And group "developers3" is a role member + When I show the group "employees3" + Then group "developers1" is a role member + And group "developers2" is a role member + And group "developers3" is a role member + + @smoke + Scenario: The !deny statement can be used to revoke permissions. Given I load a policy: """ - !variable db/password @@ -143,10 +198,69 @@ Feature: Deleting objects and relationships. """ - !deny resource: !variable db/password - privileges: [ update ] + privileges: [ update, read ] role: !host host-01 """ And I list the roles permitted to execute variable "db/password" Then the role list includes host "host-01" And I list the roles permitted to update variable "db/password" Then the role list does not include host "host-01" + And I list the roles permitted to read variable "db/password" + Then the role list does not include host "host-01" + + @smoke + Scenario: The bulk !deny statement can be used to revoke a permission from roles and members. + Given I load a policy: + """ + - !variable db/address + - !variable db/username + - !variable db/password + - !host host-01 + - !host host-02 + - !host host-03 + - !permit + resources: + - !variable db/address + - !variable db/username + - !variable db/password + privileges: [ update ] + roles: + - !host host-01 + - !host host-02 + - !host host-03 + """ + And I list the roles permitted to update variable "db/address" + Then the role list includes host "host-01" + Then the role list includes host "host-02" + Then the role list includes host "host-03" + And I list the roles permitted to update variable "db/username" + Then the role list includes host "host-01" + Then the role list includes host "host-02" + Then the role list includes host "host-03" + And I list the roles permitted to update variable "db/password" + Then the role list includes host "host-01" + Then the role list includes host "host-02" + Then the role list includes host "host-03" + And I update the policy with: + """ + - !deny + resources: + - !variable db/address + - !variable db/username + privileges: [ update ] + roles: + - !host host-01 + - !host host-02 + """ + When I list the roles permitted to update variable "db/address" + Then the role list does not include host "host-01" + And the role list does not include host "host-02" + And the role list includes host "host-03" + When I list the roles permitted to update variable "db/username" + Then the role list does not include host "host-01" + And the role list does not include host "host-02" + And the role list includes host "host-03" + When I list the roles permitted to update variable "db/password" + Then the role list includes host "host-01" + And the role list includes host "host-02" + And the role list includes host "host-03" From 7c70ce696860e1f912c222621346c8f68dbfafa6 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 9 Aug 2023 15:57:56 -0400 Subject: [PATCH 081/665] Update cucumber RestHelpers to support parallel tests The prior PR https://github.com/cyberark/conjur/pull/2818 updated our cucumber tests to use multiple processes, running tests in parallel. This required parameterizing several of the test resources to support two parallel sets of tests. This rest helper was missed in the prior PR and was using only one environment's authn-local socket rather than the socket for the given test environment. This caused some tests to fail depending on the environment in which they ran. (cherry picked from commit 7658e5d9968843ca1b88a9bb209d9db20dad06c6) --- cucumber/api/features/support/rest_helpers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index 7a80d7a09b..50cb6c7040 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -168,7 +168,7 @@ def token_protected # Write a command to the authn-local Unix socket. def authn_local_request command require 'socket' - socket_file = '/run/authn-local/.socket' + socket_file = ENV['AUTHN_LOCAL_SOCKET'] raise "Socket #{socket_file} does not exist" unless File.exist?(socket_file) UNIXSocket.open(socket_file) do |sock| From a19258a317492dd01ecb71e71759c7b88186a6bb Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Thu, 10 Aug 2023 17:44:12 +0300 Subject: [PATCH 082/665] Added an ephemeral secret call --- app/controllers/application_controller.rb | 1 + app/controllers/secrets_controller.rb | 68 +++++++++++--- app/domain/logs.rb | 19 ++++ .../conjur_ephemeral_engine_client.rb | 89 +++++++++++++++++++ .../ephemeral_engine_client.rb | 7 ++ app/models/exceptions/method_not_allowed.rb | 6 ++ 6 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb create mode 100644 app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb create mode 100644 app/models/exceptions/method_not_allowed.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2b53930016..fababebd8a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -47,6 +47,7 @@ class UnprocessableEntity < RuntimeError rescue_from Errors::Conjur::MissingSecretValue, with: :render_secret_not_found rescue_from Exceptions::RecordExists, with: :record_exists rescue_from Exceptions::Forbidden, with: :forbidden + rescue_from Exceptions::MethodNotAllowed, with: :method_not_allowed rescue_from BadRequest, with: :bad_request rescue_from Unauthorized, with: :unauthorized rescue_from InternalServerError, with: :internal_server_error diff --git a/app/controllers/secrets_controller.rb b/app/controllers/secrets_controller.rb index 0030c5d1e9..b504174207 100644 --- a/app/controllers/secrets_controller.rb +++ b/app/controllers/secrets_controller.rb @@ -8,9 +8,14 @@ class SecretsController < RestController before_action :current_user + PLATFORM_PREFIX = "platform/" + EPHEMERAL_VARIABLE_PREFIX = "data/ephemerals/" + def create authorize(:update) + raise Exceptions::MethodNotAllowed, "adding a static secret to an ephemeral secret variable is not allowed" if ephemeral_secret? + value = request.raw_post raise ArgumentError, "'value' may not be empty" if value.blank? @@ -36,15 +41,19 @@ def show authorize(:execute) version = params[:version] - unless (secret = resource.secret(version: version)) - raise Exceptions::RecordNotFound.new(\ - resource.id, message: "Requested version does not exist" - ) + if ephemeral_secret? + value = handle_ephemeral_secret + mime_type = 'application/json' + else + unless (secret = resource.secret(version: version)) + raise Exceptions::RecordNotFound.new(\ + resource.id, message: "Requested version does not exist" + ) + end + value = secret.value + mime_type = \ + resource.annotation('conjur/mime_type') || 'application/octet-stream' end - value = secret.value - - mime_type = \ - resource.annotation('conjur/mime_type') || 'application/octet-stream' send_data(value, type: mime_type) rescue Exceptions::RecordNotFound @@ -120,21 +129,21 @@ def error_info } end - # NOTE: We're following REST/http semantics here by representing this as + # NOTE: We're following REST/http semantics here by representing this as # an "expirations" that you POST to you. This may seem strange given # that what we're doing is simply updating an attribute on a secret. - # But keep in mind this purely an implementation detail -- we could + # But keep in mind this purely an implementation detail -- we could # have implemented expirations in many ways. We want to expose the - # concept of an "expiration" to the user. And per standard rest, + # concept of an "expiration" to the user. And per standard rest, # we do that with a resource, "expirations." Expiring a variable # is then a matter of POSTing to create a new "expiration" resource. - # + # # It is irrelevant that the server happens to implement this request # by assigning nil to `expires_at`. # # Unfortuneatly, to be consistent with our other routes, we're abusing # query strings to represent what is in fact a new resource. Ideally, - # we'd use a slash instead, but decided that consistency trumps + # we'd use a slash instead, but decided that consistency trumps # correctness in this case. # def expire @@ -155,4 +164,37 @@ def variable_ids @variable_ids end + + def ephemeral_secret? + resource.kind == "variable" && resource.identifier.start_with?(EPHEMERAL_VARIABLE_PREFIX) + end + + def handle_ephemeral_secret + account = params[:account] + resource_annotations = resource.annotations + variable_data = {} + request_id = request.env['action_dispatch.request_id'] + + # Filter the platform related annotations and remove the prefix + resource_annotations.each do |annotation| + next unless annotation.name.start_with?(PLATFORM_PREFIX) + platform_param = annotation.name.to_s[PLATFORM_PREFIX.length..-1] + variable_data[platform_param] = annotation.value + end + + platform = Platform.where(account: account, platform_id: variable_data["id"]).first + + # There shouldn't be a state where a variable belongs to a platform that doesn't exit, but we check it to be safe + raise ApplicationController::InternalServerError, "Platform assigned to #{account}:#{params[:kind]}:#{params[:identifier]} was not found" unless platform + + logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(variable_data["id"], platform.platform_type, variable_data["method"], request_id)) + + platform_data = { + max_ttl: platform.max_ttl, + data: JSON.parse(platform.data) + } + + ConjurEphemeralEngineClient.new(logger: logger, request_id: request_id) + .get_ephemeral_secret(platform.platform_type, variable_data["method"], @current_user.role_id, platform_data, variable_data) + end end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index d0c86df6bb..98de74c370 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -795,6 +795,25 @@ module Platforms end + module Secrets + + EphemeralSecretRequest = ::Util::TrackableLogMessageClass.new( + msg: "Received an ephemeral secret request. Platform ID [{0}], platform type [{1}], ephemeral method [{2}], Request ID [{3}]", + code: "CONJ00160I" + ) + + EphemeralSecretRemoteRequest = ::Util::TrackableLogMessageClass.new( + msg: "Calling the ephemeral secrets service. Request ID [{0}]", + code: "CONJ00161I" + ) + + EphemeralSecretRemoteResponse = ::Util::TrackableLogMessageClass.new( + msg: "Received the response from the ephemeral secrets service. Request ID [{0}], HTTP code [{1}]", + code: "CONJ00162I" + ) + + end + # This are log messages so its okay there are many # :reek:TooManyConstants module Util diff --git a/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb b/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb new file mode 100644 index 0000000000..a3818a5820 --- /dev/null +++ b/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' + +require_relative('ephemeral_engine_client') + +class ConjurEphemeralEngineClient + include EphemeralEngineClient + + def initialize(logger:, request_id:, http_client: nil) + if http_client + @client = http_client + else + @client = Net::HTTP.new("http://127.0.0.1") + @client.use_ssl = false # Service mesh takes care of the TLS communication + end + @logger = logger + @request_id = request_id + end + + def get_ephemeral_secret(type, method, role_id, platform_data, variable_data) + request_body = { + type: type, + method: method, + role: role_id, + platform: hash_keys_to_camel_case(platform_data), + secret: hash_keys_to_camel_case(variable_data) + } + + # Create the POST request + secret_request = Net::HTTP::Post.new("/secrets") + secret_request.body = request_body.as_json + + # Add headers + secret_request.add_field('Content-Type', 'application/json') + secret_request.add_field('X-Request-ID', @request_id) + secret_request.add_field('X-Tenant-ID', tenant_id) + + # Send the request and get the response + @logger.info(LogMessages::Secrets::EphemeralSecretRemoteRequest.new(@request_id)) + begin + response = @client.request(secret_request) + rescue => e + raise ApplicationController::InternalServerError, e.message + end + @logger.info(LogMessages::Secrets::EphemeralSecretRemoteResponse.new(@request_id, response.code)) + response_body = JSON.parse(response.body) + + case response.code.to_i + when 200..299 + return JSON.parse(response.body) + when 400..499 + raise ApplicationController::BadRequest, "Failed to create the ephemeral secret. Code: #{response_body['code']}, Message: #{response_body['message']}, description: #{response_body['description']}" + else + raise ApplicationController::InternalServerError, "Failed to create the ephemeral secret. Code: #{response_body['code']}, Message: #{response_body['message']}, description: #{response_body['description']}" + end + end + + protected + + def hash_keys_to_camel_case(hash, level = 0) + result = {} + delimiters = %w[- _] + hash.each do |key, value| + words = key.to_s.split(Regexp.union(delimiters)) + current_word = words[0].downcase + (1...words.length).each do |index| + current_word += words[index].capitalize + end + # If the value is another hash, perform the same casting on that sub hash. + # We don't want unexpected behavior so currently this is limited to one level of + result[current_word] = if value.is_a?(Hash) && level.zero? + hash_keys_to_camel_case(value, 1) + else + value + end + end + result + end + + def tenant_id + result = ENV["HOSTNAME"] + result.split("-")[1] || "" + rescue + "" + end +end diff --git a/app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb b/app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb new file mode 100644 index 0000000000..5494b4a92d --- /dev/null +++ b/app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module EphemeralEngineClient + def get_ephemeral_secret(type, method, role_id, platform_data, variable_data) + raise NotImplementedError, "This method is not implemented because it's an interface" + end +end diff --git a/app/models/exceptions/method_not_allowed.rb b/app/models/exceptions/method_not_allowed.rb new file mode 100644 index 0000000000..96cbef2218 --- /dev/null +++ b/app/models/exceptions/method_not_allowed.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Exceptions + class MethodNotAllowed < RuntimeError + end +end \ No newline at end of file From 7b08f29068a7a5b8d16056fe4ece92ad015c795d Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Thu, 10 Aug 2023 17:44:50 +0300 Subject: [PATCH 083/665] Added tests for the ephemeral secret retrieval flow --- cucumber/api/features/secrets.feature | 67 +++- .../conjur_ephemeral_engine_client_spec.rb | 290 ++++++++++++++++++ 2 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb diff --git a/cucumber/api/features/secrets.feature b/cucumber/api/features/secrets.feature index 8797253c96..6783b44513 100644 --- a/cucumber/api/features/secrets.feature +++ b/cucumber/api/features/secrets.feature @@ -73,9 +73,9 @@ Feature: Adding and fetching secrets @acceptance Scenario: The 'conjur/mime_type' annotation is used in the value response. - If the annotation `conjur/mime_type` exists on a resource, then when a - secret value is fetched from the resource, that mime type is used as the - `Content-Type` header in the response. + If the annotation `conjur/mime_type` exists on a resource, then when a + secret value is fetched from the resource, that mime type is used as the + `Content-Type` header in the response. Given I set annotation "conjur/mime_type" to "application/json" And I save my place in the audit log file for remote @@ -109,8 +109,8 @@ Feature: Adding and fetching secrets @smoke Scenario: The last secret is the default one returned. - Adding a new secret appends to a list of values on the resource. When - retrieving secrets, the last value in the list is returned by default. + Adding a new secret appends to a list of values on the resource. When + retrieving secrets, the last value in the list is returned by default. Given I successfully POST "/secrets/cucumber/variable/probe" with body: """ @@ -137,8 +137,8 @@ Feature: Adding and fetching secrets @acceptance Scenario: When fetching a secret, a specific secret index can be specified. - The `version` parameter can be used to select a specific secret value from - the list. + The `version` parameter can be used to select a specific secret value from + the list. Given I successfully POST "/secrets/cucumber/variable/probe" with body: """ @@ -198,3 +198,56 @@ Feature: Adding and fetching secrets """ When I GET "/secrets/cucumber/variable/probe?version=1" Then the HTTP response status code is 404 + + @negative @acceptance + Scenario: Fail when updating an ephemeral secret value with permissions + + Given I create a new "variable" resource called "data/ephemerals/ephemeral" + And I save my place in the audit log file for remote + When I POST "/secrets/cucumber/variable/data/ephemerals/ephemeral" with body: + """ + v-1 + """ + Then the HTTP response status code is 405 + And there is an audit record matching: + """ + <84>1 * * conjur * update + [auth@43868 user="cucumber:user:eve"] + [subject@43868 resource="cucumber:variable:data/ephemerals/ephemeral"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="update"] + cucumber:user:eve tried to update cucumber:variable:data/ephemerals/ephemeral: adding a static secret to an ephemeral secret variable is not allowed + """ + + @negative @acceptance + Scenario: Fail on permissions first when trying to update an ephemeral secret value without permissions + + Given I create a new "variable" resource called "ephemeral" + And I set annotation "platform/id" to "my-platform" + When I am a user named "alice" + And I POST "/secrets/cucumber/variable/ephemeral" with body: + """ + v-1 + """ + Then the HTTP response status code is 404 + And there is an error + And the error message is "Variable 'ephemeral' not found in account 'cucumber'" + + @negative @acceptance + Scenario: Fail to retrieve an ephemeral secret + + Given I create a new "variable" resource called "data/ephemerals/ephemeral" + When I GET "/secrets/cucumber/variable/data/ephemerals/ephemeral" + Then the HTTP response status code is 500 + + @acceptance + Scenario: Succeed to retrieve a secret with a platform ID that's outside the expected policy + + Given I create a new "variable" resource called "data/ephemeral" + And I set annotation "platform/id" to "my-platform" + And I POST "/secrets/cucumber/variable/data/ephemeral" with body: + """ + v-1 + """ + When I GET "/secrets/cucumber/variable/data/ephemeral" + Then the HTTP response status code is 200 diff --git a/spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb b/spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb new file mode 100644 index 0000000000..1fcdd68ce8 --- /dev/null +++ b/spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class MockConjurEngineClient < ConjurEphemeralEngineClient + def initialize(logger:, request_id:, http_client: nil) + super(logger: logger, request_id: request_id, http_client: http_client) + end + + def hash_keys_to_camel_case(hash, level = 0) + super(hash, level) + end + + def tenant_id + super + end +end + +class MockHttpResponse + def initialize(body, code) + @body = body + @code = code + end + + attr_reader :code + attr_reader :body +end + +describe "Conjur ephemeral engine client validation" do + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + let(:mock_secret_result) do + { + "id" => "b549465d-140b-475e-ada3-bc50e07d09da", + "ttl" => 1000, + "data" => { + "Credentials" => { + "AccessKeyId" => "aws_key_id", + "SecretAccessKey" => "aws_key_secret", + "SessionToken" => "session_token", + "Expiration" => "2023-07-03T15:28:23+00:00" + }, + "FederatedUser" => { + "FederatedUserId" => "238637036211:conjur,host,data.my-app", + "Arn" => "arn:aws:sts::238637036211:federated-user/conjur,host,data.my-app" + }, + "PackedPolicySize" => 16 + } + } + end + + let(:mock_secret_error) do + { + "code" => "Error code", + "message" => "Error message", + "description" => "Error description" + } + end + + def mock_ephemeral_secrets_service(response_code) + double('net_http_post').tap do |net_http_post| + if response_code + allow(net_http_post).to receive(:request) + .and_return(MockHttpResponse.new(JSON.generate(mock_secret_error), response_code)) + else + allow(net_http_post).to receive(:request) + .and_return(MockHttpResponse.new(JSON.generate(mock_secret_result), 201)) + end + end + end + + context "when all input is valid" do + it "then an ephemeral secret is returned" do + platform_type = "aws" + platform_method = "iam_federation" + role_id = "conjur:host:data/my-host" + platform_data = { + max_ttl: 3000, + data: { + access_key_id: "my_key_id", + access_key_secret: "my_secret_key" + } + } + variable_data = { + "max-ttl": 1000, + "aws-region": "us-east-1", + "policy": "my_inline_policy" + } + + expect do + MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(nil)) + .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + end + .to_not raise_error + + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(nil)) + .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + + expect(result).to eq(mock_secret_result) + end + end + + context "when there are failures from the ephemeral secrets service" do + it "then the appropriate exception is raised" do + platform_type = "aws" + platform_method = "iam_federation" + role_id = "conjur:host:data/my-host" + platform_data = { + data: { + access_key_id: "my_key_id", + access_key_secret: "my_secret_key" + } + } + variable_data = { + "max-ttl": 1000, + "aws-region": "us-east-1", + "policy": "my_inline_policy" + } + + expect do + MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service("400")) + .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + end.to raise_error(ApplicationController::BadRequest) do |error| + expect(error.message).to eq("Failed to create the ephemeral secret. Code: Error code, Message: Error message, description: Error description") + end + + expect do + MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service("500")) + .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + end.to raise_error(ApplicationController::InternalServerError) do |error| + expect(error.message).to eq("Failed to create the ephemeral secret. Code: Error code, Message: Error message, description: Error description") + end + end + end + + context "when hash keys have hyphens, underscores or start with upper case" do + let(:hash) do + { + "key-one": "value-one", + "key_two": "value_two", + "KeyThree": "ValueThree" + } + end + + it "then they are trimmed and turned to upper case" do + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) + .hash_keys_to_camel_case(hash) + expected_result = { + "keyOne" => "value-one", + "keyTwo" => "value_two", + "keythree" => "ValueThree" + } + + expect(result).to eq(expected_result) + end + end + + context "when hash values are a hash as well" do + let(:hash) do + { + "key-one": "value-one", + "key_two": "value_two", + "data": { + "sub_key_one": "sub-key-value", + "sub-key-two": "sub_key_value" + } + } + end + + it "then the sub hash is transformed as well" do + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) + .hash_keys_to_camel_case(hash) + expected_result = { + "keyOne" => "value-one", + "keyTwo" => "value_two", + "data" => { + "subKeyOne" => "sub-key-value", + "subKeyTwo" => "sub_key_value" + } + } + + expect(result).to eq(expected_result) + end + end + + context "when hash keys are strings and not symbols" do + let(:hash) do + { + "key-one" => "value-one", + "key_two" => "value_two", + "keyThree" => "valueThree" + } + end + + it "then they are transformed ok" do + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) + .hash_keys_to_camel_case(hash) + expected_result = { + "keyOne" => "value-one", + "keyTwo" => "value_two", + "keythree" => "valueThree" + } + + expect(result).to eq(expected_result) + end + end + + context "when hash keys have various upper and lower case letters" do + let(:hash) do + { + "keY-oNE" => "value-one", + "kEy_Two" => "value_two", + "keyThRee" => "valueThree" + } + end + + it "then they are normalized into camel case" do + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) + .hash_keys_to_camel_case(hash) + expected_result = { + "keyOne" => "value-one", + "keyTwo" => "value_two", + "keythree" => "valueThree" + } + + expect(result).to eq(expected_result) + end + end + + context "when hash keys have various symbols" do + let(:hash) do + { + "keY-oNE#" => "value-one", + "kEy_%two" => "value_two", + "keyThRe*e" => "valueThree" + } + end + + it "then they are normalized into camel case" do + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) + .hash_keys_to_camel_case(hash) + expected_result = { + "keyOne#" => "value-one", + "key%two" => "value_two", + "keythre*e" => "valueThree" + } + + expect(result).to eq(expected_result) + end + end + + context "when hash is empty" do + let(:hash) {{}} + + it "then the result is empty as well" do + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) + .hash_keys_to_camel_case(hash) + expected_result = {} + + expect(result).to eq(expected_result) + end + end + + context "when the hostname is parsed for the tenant ID" do + it "then the tenant ID is successfully found" do + ENV["HOSTNAME"] = "cnj-my_tenant_id-value1-value2" + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)).tenant_id + + expect(result).to eq("my_tenant_id") + end + end + + context "when the hostname has an unexpected value" do + it "then the tenant ID is empty" do + ENV["HOSTNAME"] = "some_unexpected_value" + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)).tenant_id + + expect(result).to eq("") + end + end + + context "when the hostname does not exist" do + it "then the tenant ID is empty" do + ENV["HOSTNAME"] = "" + result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)).tenant_id + + expect(result).to eq("") + end + end +end From fce5cd349c0937406cd2173be1a97be8f694c46e Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Thu, 10 Aug 2023 17:45:30 +0300 Subject: [PATCH 084/665] Updated the CHANGELOG with the ephemeral secret retrieval feature --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7760b48278..b00d225235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Support plural syntax for revoke and deny [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) +### Added +- Added a call to the ephemeral secrets service when an ephemeral secret is requested https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42995 + ## [1.0.3-cloud] - 2023-07-31 ### Added - Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 From 2a6a3a6a8669ab9ed08f05e5bf35cf01971d95a7 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 14 Aug 2023 09:46:47 +0300 Subject: [PATCH 085/665] ONYX-42767- Change get all hosts replication in edge controller to retrieve the all annotations for each host. --- app/controllers/edge_controller.rb | 16 +++++++++------- app/models/annotation.rb | 2 +- app/models/role.rb | 18 ++++++++++++++++++ cucumber/api/features/edge.feature | 16 +++++++++++++++- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb index 24dd7ca562..9f2550a04a 100644 --- a/app/controllers/edge_controller.rb +++ b/app/controllers/edge_controller.rb @@ -152,14 +152,16 @@ def all_hosts render(json: results) else results = [] - hosts = scope.eager(:credentials).all + roles_with_creds = scope.eager(:credentials) + hosts = Role.roles_with_annotations(roles_with_creds).all hosts.each do |host| - hostToReturn = {} - hostToReturn[:id] = host[:role_id] - salt = OpenSSL::Random.random_bytes(32) - hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) - hostToReturn[:salt] = Base64.strict_encode64(salt) - hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + hostToReturn = {} + hostToReturn[:id] = host[:role_id] + salt = OpenSSL::Random.random_bytes(32) + hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + hostToReturn[:salt] = Base64.strict_encode64(salt) + hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + hostToReturn[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) results << hostToReturn end logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( diff --git a/app/models/annotation.rb b/app/models/annotation.rb index 2d8d1b6c4a..05120062da 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -8,7 +8,7 @@ class Annotation < Sequel::Model unrestrict_primary_key many_to_one :resource, reciprocal: :annotations - + many_to_one :role, :key => :role_id, :class => :Role def as_json options = {} options[:except] ||= [] options[:except].push(:resource_id) diff --git a/app/models/role.rb b/app/models/role.rb index 6785216f27..bce7506256 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -19,6 +19,9 @@ class Role < Sequel::Model extend: MembershipSearch, search_key: :role_id ) + + one_to_many :annotations, :key => :resource_id, :class => :Annotation + one_to_one :credentials, reciprocal: :role alias id role_id @@ -63,6 +66,21 @@ def username_from_roleid(roleid) [kind, id].join('/') end + + def roles_with_annotations(roles) + #this subquery aggregates all annotation of a role to an array of key value pairs, key- annotation name, value- annotation value + subquery = Sequel.function(:jsonb_agg, + Sequel.case( + { Sequel.~(:name => nil) => + Sequel.function(:jsonb_build_object, + "name", :name, + "value", :value + )}, + nil + )).as(:annotations) + roles.left_join(:annotations, resource_id: :role_id).group_by(:role_id).select_group(:role_id) + .select_append(subquery) + end end dataset_module do diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index b9a9ca0f5b..398b29b4bd 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -4,7 +4,6 @@ Feature: Fetching secrets from edge endpoint Background: Given I create a new user "some_user" And I create a new user "admin_user" - And I have host "data/some_host1" And I have host "data/some_host2" And I have host "data/some_host3" And I have host "data/some_host4" @@ -45,6 +44,12 @@ Feature: Fetching secrets from edge endpoint - !variable secret4 - !variable secret5 - !variable secret6 + - !host + id: some_host1 + annotations: + authn/api-key: true + test2: test1 + test: - !permit role: !host some_host1 privilege: [ execute ] @@ -579,6 +584,15 @@ Feature: Fetching secrets from edge endpoint And the JSON response at "hosts" should have 5 entries And the JSON response should not have "database" And the JSON response should not have "other_host" + And the JSON at "hosts/0/annotations" should be: + """ + [{"name": "test2", "value": "test1"}, {"name": "test", "value": ""}, {"name": "authn/api-key", "value": "true"}] + """ + And the JSON at "hosts/1/annotations" should be: + """ + [] + """ + @acceptance Scenario: Fetching hosts with parameters From 6bc6c1513260dcf224b53897e3df4b7a2227400a Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 13 Aug 2023 09:15:42 +0300 Subject: [PATCH 086/665] Fix CONJSE-1785 --- CHANGELOG.md | 12 +++++- app/models/loader/orchestrate.rb | 4 +- cucumber/policy/features/deletion.feature | 45 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b00d225235..484eadece7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.5-cloud] - 2023-08-16 +### Security +- Previously, attempting to add and remove a privilege in the same policy load + resulted in only the positive privilege (grant, permit) taking effect. Now we + fail safe and the negative privilege statement (revoke, deny) is the final + outcome + [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) + ## [1.0.4-cloud] - 2023-08-10 -### Fixed -- Support plural syntax for revoke and deny +### Security +- Support plural syntax for revoke and deny [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) ### Added diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index b855bb5ccf..f9666d302b 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -107,8 +107,6 @@ def setup_db_for_new_policy @extensions.call(:before_load_policy, policy_version: @policy_version) end - perform_deletion - create_schema load_records @@ -129,6 +127,8 @@ def store_policy_in_db drop_schema + perform_deletion + store_passwords store_public_keys diff --git a/cucumber/policy/features/deletion.feature b/cucumber/policy/features/deletion.feature index 02020a5858..9f6ac3b285 100644 --- a/cucumber/policy/features/deletion.feature +++ b/cucumber/policy/features/deletion.feature @@ -71,6 +71,12 @@ Feature: Deleting objects and relationships. body: - !delete record: !variable db-password + """ + And I load a policy: + """ + - !policy + id: test + body: - !variable db-password """ Then variable "test/db-password" exists @@ -264,3 +270,42 @@ Feature: Deleting objects and relationships. Then the role list includes host "host-01" And the role list includes host "host-02" And the role list includes host "host-03" + + @smoke + Scenario: Delete statements prevail on conflicting policy statements + If a policy contains both adding and deleting statements (delete, deny, revoke), + then we want to ensure that we fail safe and the delete statement is the final outcome. + Given I update the policy with: + """ + - !variable db/password + - !host host-01 + - !permit + resource: !variable db/password + privileges: [ execute ] + role: !host host-01 + - !deny + resource: !variable db/password + privileges: [ execute ] + role: !host host-01 + """ + When I list the roles permitted to execute variable "db/password" + Then the role list does not include host "host-01" + Given I update the policy with: + """ + - !group hosts + - !grant + role: !host host-01 + member: !group hosts + - !revoke + role: !host host-01 + member: !group hosts + """ + When I show the group "hosts" + Then host "host-01" is not a role member + Given I update the policy with: + """ + - !variable to_be_deleted + - !delete + record: !variable to_be_deleted + """ + Then variable "to_be_deleted" does not exist From 825c8a6540cbc523c828d8dad5c599a336dfa352 Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 20 Aug 2023 13:58:17 +0300 Subject: [PATCH 087/665] Report data without install --- .../edge_logic/data_handlers/install_handler.rb | 6 +++++- .../edge_logic/data_handlers/ongoing_handler.rb | 1 + spec/controllers/edge_controller_spec.rb | 13 +++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/domain/edge_logic/data_handlers/install_handler.rb b/app/domain/edge_logic/data_handlers/install_handler.rb index dbf8e53995..e1ca0290b2 100644 --- a/app/domain/edge_logic/data_handlers/install_handler.rb +++ b/app/domain/edge_logic/data_handlers/install_handler.rb @@ -13,7 +13,7 @@ def call(params, hostname, ip) begin edge = Edge.get_by_hostname(hostname) installation_date = params.require(:installation_date) - edge.update(installation_date: Time.at(installation_date)) + InstallHandler.update_installation_date(edge, installation_date) rescue => e @error_message = e.message raise e @@ -22,6 +22,10 @@ def call(params, hostname, ip) end end + def self.update_installation_date(edge, installation_date) + edge.update(installation_date: Time.at(installation_date)) + end + private def audit_installed(edge_name = "not-found", ip) diff --git a/app/domain/edge_logic/data_handlers/ongoing_handler.rb b/app/domain/edge_logic/data_handlers/ongoing_handler.rb index eb803cc70f..b92238f1b3 100644 --- a/app/domain/edge_logic/data_handlers/ongoing_handler.rb +++ b/app/domain/edge_logic/data_handlers/ongoing_handler.rb @@ -17,6 +17,7 @@ def call(params, hostname, ip) validate_params(options, input_validator) edge = Edge.get_by_hostname(hostname) + InstallHandler.update_installation_date(edge, -1) unless edge.installation_date edge.record_edge_access(options, ip) # Log Edge statistics to be collected by Datadog stats = options['edge_statistics'] diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb index df173c471e..17e45cf78f 100644 --- a/spec/controllers/edge_controller_spec.rb +++ b/spec/controllers/edge_controller_spec.rb @@ -230,5 +230,18 @@ def send_request_with_correct_role .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("422") end + + it "Report works even without installation" do + edgy = Edge["1234"] + edgy.update(installation_date: nil) + + edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + edgy = Edge["1234"] + expect(edgy.installation_date).to eq(Time.at(-1)) + end end end From a57c7458904447a3bc560e3fd879ccc14b763d65 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 21 Aug 2023 10:22:51 +0300 Subject: [PATCH 088/665] ONYX-42767- fix annotations test --- cucumber/api/features/edge.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature index 398b29b4bd..d92563b0ba 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge.feature @@ -584,9 +584,9 @@ Feature: Fetching secrets from edge endpoint And the JSON response at "hosts" should have 5 entries And the JSON response should not have "database" And the JSON response should not have "other_host" - And the JSON at "hosts/0/annotations" should be: + And the JSON at "hosts/0/annotations" should include: """ - [{"name": "test2", "value": "test1"}, {"name": "test", "value": ""}, {"name": "authn/api-key", "value": "true"}] + {"name": "test2", "value": "test1"}, {"name": "test", "value": ""}, {"name": "authn/api-key", "value": "true"} """ And the JSON at "hosts/1/annotations" should be: """ From 6801259c1c96884e2ff1c4949ce2d551b0885143 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Mon, 21 Aug 2023 15:51:58 +0300 Subject: [PATCH 089/665] ONYX-43436: Refactor edge endpoint to split to dedicated files --- CHANGELOG.md | 2 + .../edge/api/edge_configuration_controller.rb | 22 ++ .../api/edge_creation_controller.rb} | 28 +- .../edge/api/edge_visibility_controller.rb | 19 ++ .../edge/internal/edge_handler_controller.rb | 25 ++ .../edge/internal/edge_hosts_controller.rb | 66 +++++ .../edge/internal/edge_secrets_controller.rb | 117 ++++++++ .../internal/edge_slosilo_keys_controller.rb | 47 +++ app/controllers/edge_controller.rb | 269 ------------------ config/routes.rb | 18 +- .../edge/api/edge_creation_controller_spec.rb | 59 ++++ .../api/edge_visibility_controller_spec.rb | 101 +++++++ .../internal/edge_handler_controller_spec.rb | 89 ++++++ .../internal/edge_hosts_controller_spec.rb | 37 +++ .../edge_slosilo_keys_controller_spec.rb | 76 +++++ spec/controllers/edge_controller_spec.rb | 247 ---------------- .../edge_creator_controller_spec.rb | 20 -- 17 files changed, 695 insertions(+), 547 deletions(-) create mode 100644 app/controllers/edge/api/edge_configuration_controller.rb rename app/controllers/{edge_creator_controller.rb => edge/api/edge_creation_controller.rb} (62%) create mode 100644 app/controllers/edge/api/edge_visibility_controller.rb create mode 100644 app/controllers/edge/internal/edge_handler_controller.rb create mode 100644 app/controllers/edge/internal/edge_hosts_controller.rb create mode 100644 app/controllers/edge/internal/edge_secrets_controller.rb create mode 100644 app/controllers/edge/internal/edge_slosilo_keys_controller.rb delete mode 100644 app/controllers/edge_controller.rb create mode 100644 spec/controllers/edge/api/edge_creation_controller_spec.rb create mode 100644 spec/controllers/edge/api/edge_visibility_controller_spec.rb create mode 100644 spec/controllers/edge/internal/edge_handler_controller_spec.rb create mode 100644 spec/controllers/edge/internal/edge_hosts_controller_spec.rb create mode 100644 spec/controllers/edge/internal/edge_slosilo_keys_controller_spec.rb delete mode 100644 spec/controllers/edge_controller_spec.rb delete mode 100644 spec/controllers/edge_creator_controller_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 484eadece7..cde29b9b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.6-cloud] - 2023-08-21 + ## [1.0.5-cloud] - 2023-08-16 ### Security - Previously, attempting to add and remove a privilege in the same policy load diff --git a/app/controllers/edge/api/edge_configuration_controller.rb b/app/controllers/edge/api/edge_configuration_controller.rb new file mode 100644 index 0000000000..89598075c9 --- /dev/null +++ b/app/controllers/edge/api/edge_configuration_controller.rb @@ -0,0 +1,22 @@ +class EdgeConfigurationController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def max_edges_allowed + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/max-allowed")) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + validate_conjur_admin_group(options[:account]) + begin + secret_value = extract_max_edge_value(options[:account]) + render(plain: secret_value, content_type: "text/plain") + rescue Exceptions::RecordNotFound + raise RecordNotFound, "The request failed because max-edge-allowed secret doesn't exist" + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/max-allowed")) + end +end \ No newline at end of file diff --git a/app/controllers/edge_creator_controller.rb b/app/controllers/edge/api/edge_creation_controller.rb similarity index 62% rename from app/controllers/edge_creator_controller.rb rename to app/controllers/edge/api/edge_creation_controller.rb index 8d3c9b6426..9703c6546f 100644 --- a/app/controllers/edge_creator_controller.rb +++ b/app/controllers/edge/api/edge_creation_controller.rb @@ -1,7 +1,7 @@ -require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../../wrappers/policy_wrapper' -class EdgeCreatorController < RestController +class EdgeCreationController < RestController include AccountValidator include BodyParser include EdgeValidator @@ -11,6 +11,30 @@ class EdgeCreatorController < RestController include PolicyWrapper include ParamsValidator + def generate_install_token + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/edge-creds")) + allowed_params = %i[account edge_name] + options = params.permit(*allowed_params).to_h.symbolize_keys + audit_params = { edge_name: options[:edge_name], user: current_user.role_id, client_ip: request.ip } + begin + validate_conjur_admin_group(options[:account]) + + edge = Edge[name: options[:edge_name]] || (raise RecordNotFound.new(options[:edge_name], message: "Edge #{options[:edge_name]} not found")) + installer_token = edge.get_installer_token(options[:account], request) + + edge_host_name = Role.username_from_roleid(edge.get_edge_host_name(options[:account])) + + rescue => e + audit_params[:error_message] = e.message + raise e + ensure + Audit.logger.log(Audit::Event::CredsGeneration.new(**audit_params)) + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edge-creds")) + response.set_header("Content-Encoding", "base64") + render(plain: Base64.strict_encode64(edge_host_name + ":" + installer_token)) + end + #this endpoint loads a policy with the edge host values + adds the edge name to Edge table def create_edge logger.info(LogMessages::Endpoints::EndpointRequested.new('create edge')) diff --git a/app/controllers/edge/api/edge_visibility_controller.rb b/app/controllers/edge/api/edge_visibility_controller.rb new file mode 100644 index 0000000000..b7507b50ed --- /dev/null +++ b/app/controllers/edge/api/edge_visibility_controller.rb @@ -0,0 +1,19 @@ +class EdgeVisibilityController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def all_edges + logger.info(LogMessages::Endpoints::EndpointRequested.new("edge")) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + validate_conjur_admin_group(options[:account]) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge")) + render(json: Edge.order(:name).all.map{|edge| + {name: edge.name, ip: edge.ip, last_sync: edge.last_sync.to_i, + version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) + end +end \ No newline at end of file diff --git a/app/controllers/edge/internal/edge_handler_controller.rb b/app/controllers/edge/internal/edge_handler_controller.rb new file mode 100644 index 0000000000..70c3e33fd6 --- /dev/null +++ b/app/controllers/edge/internal/edge_handler_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class EdgeHandlerController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def report_edge_data + logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/data')) + allowed_params = %i[account data_type] + url_params = params.permit(*allowed_params) + verify_edge_host(url_params) + data_handlers = {'install' => EdgeLogic::DataHandlers::InstallHandler , 'ongoing' => EdgeLogic::DataHandlers::OngoingHandler} + handler = data_handlers[url_params[:data_type]] + raise BadRequest unless handler + + handler.new(logger).call(params, current_user.role_id, request.ip) + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/data")) + end + +end diff --git a/app/controllers/edge/internal/edge_hosts_controller.rb b/app/controllers/edge/internal/edge_hosts_controller.rb new file mode 100644 index 0000000000..4b9176a7a1 --- /dev/null +++ b/app/controllers/edge/internal/edge_hosts_controller.rb @@ -0,0 +1,66 @@ +class EdgeHostsController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def all_hosts + logger.info(LogMessages::Endpoints::EndpointRequested.new("all_hosts")) + + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + scope = Role.where(:role_id.like(options[:account] + ":host:data/%")) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + scope = scope.order(:role_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + if params[:count] == 'true' + results = { count: sumItems } + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts:count")) + render(json: results) + else + results = [] + roles_with_creds = scope.eager(:credentials) + hosts = Role.roles_with_annotations(roles_with_creds).all + hosts.each do |host| + hostToReturn = {} + hostToReturn[:id] = host[:role_id] + salt = OpenSSL::Random.random_bytes(32) + hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + hostToReturn[:salt] = Base64.strict_encode64(salt) + hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + hostToReturn[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) + results << hostToReturn + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_hosts", + limit, + offset + )) + render(json: { "hosts": results }) + end + end + + private + + def numeric? val + val == val.to_i.to_s + end +end \ No newline at end of file diff --git a/app/controllers/edge/internal/edge_secrets_controller.rb b/app/controllers/edge/internal/edge_secrets_controller.rb new file mode 100644 index 0000000000..5ee83eea7d --- /dev/null +++ b/app/controllers/edge/internal/edge_secrets_controller.rb @@ -0,0 +1,117 @@ +class EdgeSecretsController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + # Return all secrets within offset-limit frame. Default is 0-1000 + def all_secrets + logger.info(LogMessages::Endpoints::EndpointRequested.new("all_secrets")) + + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + + scope = Resource.where(:resource_id.like(options[:account] + ":variable:data/%")) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] || "0" + limit = options[:limit] || "1000" + validate_scope(limit, offset) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + + if params[:count] == 'true' + results = { count: sumItems } + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets:count")) + render(json: results) + else + results = [] + failed = [] + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + if accepts_base64 + response.set_header("Content-Encoding", "base64") + end + + variables = build_variables_map(limit, offset, options) + + variables.each do |id, variable| + variableToReturn = {} + variableToReturn[:id] = id + variableToReturn[:owner] = variable[:owner_id] + variableToReturn[:permissions] = [] + Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| + permission = {} + permission[:privilege] = row[:privilege] + permission[:resource] = row[:resource_id] + permission[:role] = row[:role_id] + permission[:policy] = row[:policy_id] + variableToReturn[:permissions].append(permission) + end + secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) + variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value + variableToReturn[:version] = variable[:version] + variableToReturn[:versions] = [] + value = { + "version": variableToReturn[:version], + "value": variableToReturn[:value] + } + variableToReturn[:versions] << value + begin + JSON.generate(variableToReturn) + results << variableToReturn + rescue => e + failed << { "id": id } + end + + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_secrets", + limit, + offset + )) + if (failed.size > 0) + logger.info(LogMessages::Util::FailedSerializationOfResources.new( + "all_secrets", + limit, + offset, + failed.size, + failed.first + )) + end + render(json: { "secrets": results, "failed": failed }) + end + end + + + private + + def build_variables_map(limit, offset, options) + variables = {} + + Sequel::Model.db.fetch("SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE '" + options[:account] + ":variable:data/%') ORDER BY resource_id LIMIT " + limit.to_s + " OFFSET " + offset.to_s + ") AS res ON (res.resource_id = secrets.resource_id)") do |row| + if variables.key?(row[:resource_id]) + if row[:version] > variables[row[:resource_id]][:version] + variables[row[:resource_id]] = row + end + else + variables[row[:resource_id]] = row + end + end + variables + end + + def numeric? val + val == val.to_i.to_s + end + +end \ No newline at end of file diff --git a/app/controllers/edge/internal/edge_slosilo_keys_controller.rb b/app/controllers/edge/internal/edge_slosilo_keys_controller.rb new file mode 100644 index 0000000000..fad98ad047 --- /dev/null +++ b/app/controllers/edge/internal/edge_slosilo_keys_controller.rb @@ -0,0 +1,47 @@ +class EdgeSlosiloKeysController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def slosilo_keys + logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + rescue ApplicationController::Forbidden + raise + end + account = options[:account] + + key = Account.token_key(account, "host") + if key.nil? + raise RecordNotFound, "No Slosilo key in DB" + end + return_json = {} + key_object = [get_key_object(key)] + return_json[:slosiloKeys] = key_object + + prev_key = Account.token_key(account, "host", "previous") + prev_key_obj = prev_key.nil? ? [] : [get_key_object(prev_key)] + return_json[:previousSlosiloKeys] = prev_key_obj + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("slosilo_keys")) + render(json: return_json) + end + + private + + def get_key_object(key) + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + variable_to_return = {} + variable_to_return[:privateKey] = private_key + variable_to_return[:fingerprint] = fingerprint + variable_to_return + end + +end \ No newline at end of file diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb deleted file mode 100644 index 9f2550a04a..0000000000 --- a/app/controllers/edge_controller.rb +++ /dev/null @@ -1,269 +0,0 @@ -# frozen_string_literal: true - -class EdgeController < RestController - include AccountValidator - include BodyParser - include Cryptography - include EdgeValidator - include ExtractEdgeResources - include GroupMembershipValidator - - def slosilo_keys - logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) - allowed_params = %i[account] - options = params.permit(*allowed_params).to_h.symbolize_keys - begin - verify_edge_host(options) - rescue ApplicationController::Forbidden - raise - end - account = options[:account] - - key = Account.token_key(account, "host") - if key.nil? - raise RecordNotFound, "No Slosilo key in DB" - end - return_json = {} - key_object = [get_key_object(key)] - return_json[:slosiloKeys] = key_object - - prev_key = Account.token_key(account, "host", "previous") - prev_key_obj = prev_key.nil? ? [] : [get_key_object(prev_key)] - return_json[:previousSlosiloKeys] = prev_key_obj - - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("slosilo_keys")) - render(json: return_json) - end - - # Return all secrets within offset-limit frame. Default is 0-1000 - def all_secrets - logger.info(LogMessages::Endpoints::EndpointRequested.new("all_secrets")) - - allowed_params = %i[account limit offset] - options = params.permit(*allowed_params) - .slice(*allowed_params).to_h.symbolize_keys - begin - verify_edge_host(options) - - scope = Resource.where(:resource_id.like(options[:account] + ":variable:data/%")) - if params[:count] == 'true' - sumItems = scope.count('*'.lit) - else - offset = options[:offset] || "0" - limit = options[:limit] || "1000" - validate_scope(limit, offset) - end - rescue ApplicationController::Forbidden - raise - rescue ArgumentError => e - raise ApplicationController::UnprocessableEntity, e.message - end - - if params[:count] == 'true' - results = { count: sumItems } - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets:count")) - render(json: results) - else - results = [] - failed = [] - accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') - if accepts_base64 - response.set_header("Content-Encoding", "base64") - end - - variables = build_variables_map(limit, offset, options) - - variables.each do |id, variable| - variableToReturn = {} - variableToReturn[:id] = id - variableToReturn[:owner] = variable[:owner_id] - variableToReturn[:permissions] = [] - Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| - permission = {} - permission[:privilege] = row[:privilege] - permission[:resource] = row[:resource_id] - permission[:role] = row[:role_id] - permission[:policy] = row[:policy_id] - variableToReturn[:permissions].append(permission) - end - secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) - variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value - variableToReturn[:version] = variable[:version] - variableToReturn[:versions] = [] - value = { - "version": variableToReturn[:version], - "value": variableToReturn[:value] - } - variableToReturn[:versions] << value - begin - JSON.generate(variableToReturn) - results << variableToReturn - rescue => e - failed << { "id": id } - end - - end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( - "all_secrets", - limit, - offset - )) - if (failed.size > 0) - logger.info(LogMessages::Util::FailedSerializationOfResources.new( - "all_secrets", - limit, - offset, - failed.size, - failed.first - )) - end - render(json: { "secrets": results, "failed": failed }) - end - end - - def all_hosts - logger.info(LogMessages::Endpoints::EndpointRequested.new("all_hosts")) - - allowed_params = %i[account limit offset] - options = params.permit(*allowed_params) - .slice(*allowed_params).to_h.symbolize_keys - begin - verify_edge_host(options) - scope = Role.where(:role_id.like(options[:account] + ":host:data/%")) - if params[:count] == 'true' - sumItems = scope.count('*'.lit) - else - offset = options[:offset] - limit = options[:limit] - validate_scope(limit, offset) - scope = scope.order(:role_id).limit( - (limit || 1000).to_i, - (offset || 0).to_i - ) - end - rescue ApplicationController::Forbidden - raise - rescue ArgumentError => e - raise ApplicationController::UnprocessableEntity, e.message - end - if params[:count] == 'true' - results = { count: sumItems } - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts:count")) - render(json: results) - else - results = [] - roles_with_creds = scope.eager(:credentials) - hosts = Role.roles_with_annotations(roles_with_creds).all - hosts.each do |host| - hostToReturn = {} - hostToReturn[:id] = host[:role_id] - salt = OpenSSL::Random.random_bytes(32) - hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) - hostToReturn[:salt] = Base64.strict_encode64(salt) - hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } - hostToReturn[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) - results << hostToReturn - end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( - "all_hosts", - limit, - offset - )) - render(json: { "hosts": results }) - end - end - - def generate_install_token - logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/edge-creds")) - allowed_params = %i[account edge_name] - options = params.permit(*allowed_params).to_h.symbolize_keys - audit_params = { edge_name: options[:edge_name], user: current_user.role_id, client_ip: request.ip } - begin - validate_conjur_admin_group(options[:account]) - - edge = Edge[name: options[:edge_name]] || (raise RecordNotFound.new(options[:edge_name], message: "Edge #{options[:edge_name]} not found")) - installer_token = edge.get_installer_token(options[:account], request) - - edge_host_name = Role.username_from_roleid(edge.get_edge_host_name(options[:account])) - - rescue => e - audit_params[:error_message] = e.message - raise e - ensure - Audit.logger.log(Audit::Event::CredsGeneration.new(**audit_params)) - end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edge-creds")) - response.set_header("Content-Encoding", "base64") - render(plain: Base64.strict_encode64(edge_host_name + ":" + installer_token)) - end - - def all_edges - logger.info(LogMessages::Endpoints::EndpointRequested.new("edge")) - allowed_params = %i[account] - options = params.permit(*allowed_params).to_h.symbolize_keys - validate_conjur_admin_group(options[:account]) - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge")) - render(json: Edge.order(:name).all.map{|edge| - {name: edge.name, ip: edge.ip, last_sync: edge.last_sync.to_i, - version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) - end - - def max_edges_allowed - logger.info(LogMessages::Endpoints::EndpointRequested.new("edge/max-allowed")) - allowed_params = %i[account] - options = params.permit(*allowed_params).to_h.symbolize_keys - validate_conjur_admin_group(options[:account]) - begin - secret_value = extract_max_edge_value(options[:account]) - render(plain: secret_value, content_type: "text/plain") - rescue Exceptions::RecordNotFound - raise RecordNotFound, "The request failed because max-edge-allowed secret doesn't exist" - end - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/max-allowed")) - end - - def report_edge_data - logger.info(LogMessages::Endpoints::EndpointRequested.new('edge/data')) - allowed_params = %i[account data_type] - url_params = params.permit(*allowed_params) - verify_edge_host(url_params) - data_handlers = {'install' => EdgeLogic::DataHandlers::InstallHandler , 'ongoing' => EdgeLogic::DataHandlers::OngoingHandler} - handler = data_handlers[url_params[:data_type]] - raise BadRequest unless handler - - handler.new(logger).call(params, current_user.role_id, request.ip) - - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/data")) - end - - private - - def get_key_object(key) - private_key = key.to_der.unpack("H*")[0] - fingerprint = key.fingerprint - variable_to_return = {} - variable_to_return[:privateKey] = private_key - variable_to_return[:fingerprint] = fingerprint - variable_to_return - end - - def build_variables_map(limit, offset, options) - variables = {} - - Sequel::Model.db.fetch("SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE '" + options[:account] + ":variable:data/%') ORDER BY resource_id LIMIT " + limit.to_s + " OFFSET " + offset.to_s + ") AS res ON (res.resource_id = secrets.resource_id)") do |row| - if variables.key?(row[:resource_id]) - if row[:version] > variables[row[:resource_id]][:version] - variables[row[:resource_id]] = row - end - else - variables[row[:resource_id]] = row - end - end - variables - end - - def numeric? val - val == val.to_i.to_s - end - -end diff --git a/config/routes.rb b/config/routes.rb index 33049221dd..d70bcdcd56 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,15 +78,15 @@ def matches?(request) get "/secrets/:account/:kind/*identifier" => 'secrets#show' post "/secrets/:account/:kind/*identifier" => 'secrets#create' get "/secrets" => 'secrets#batch' - post "/edge/:account" => 'edge_creator#create_edge' - get "/edge/secrets/:account" => 'edge#all_secrets' - get "/edge/hosts/:account" => 'edge#all_hosts' - get "edge/edge-creds/:account/:edge_name" => 'edge#generate_install_token' - get "/edge/:account" => 'edge#all_edges' - get "/edge/max-allowed/:account" => 'edge#max_edges_allowed' - - post "/edge/data/:account" => 'edge#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") - get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' + post "/edge/:account" => 'edge_creation#create_edge' + get "/edge/secrets/:account" => 'edge_secrets#all_secrets' + get "/edge/hosts/:account" => 'edge_hosts#all_hosts' + get "edge/edge-creds/:account/:edge_name" => 'edge_creation#generate_install_token' + get "/edge/:account" => 'edge_visibility#all_edges' + get "/edge/max-allowed/:account" => 'edge_configuration#max_edges_allowed' + + post "/edge/data/:account" => 'edge_handler#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") + get "/edge/slosilo_keys/:account" => 'edge_slosilo_keys#slosilo_keys' post "/platforms/:account" => 'platforms#create' delete "/platforms/:account/:identifier" => 'platforms#delete' diff --git a/spec/controllers/edge/api/edge_creation_controller_spec.rb b/spec/controllers/edge/api/edge_creation_controller_spec.rb new file mode 100644 index 0000000000..2c117a5f04 --- /dev/null +++ b/spec/controllers/edge/api/edge_creation_controller_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe EdgeCreationController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} + let(:admin_user_id) {"#{account}:user:admin_user"} + let(:other_host_id) {"#{account}:host:data/other"} + + let(:edge_creds) do + "/edge/edge-creds/#{account}" + end + + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: host_id) + @other_user = Role.find_or_create(role_id: other_host_id) + @admin_user = Role.find_or_create(role_id: admin_user_id) + end + + context "Edge name validation" do + subject{ EdgeCreationController.new } + + it "Edge names are validated" do + expect { subject.send(:validate_name, "Edgy") }.to_not raise_error + expect { subject.send(:validate_name, "Edgy_05") }.to_not raise_error + + expect { subject.send(:validate_name, nil) }.to raise_error + expect { subject.send(:validate_name, "") }.to raise_error + expect { subject.send(:validate_name, "Edgy!") }.to raise_error + expect { subject.send(:validate_name, "SuperExtremelyLongEdgeName") }.to raise_error + end + end + + context "Installation" do + before do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + end + + it "Generate script error cases" do + #Missing edge + get("#{edge_creds}/non-existent", env: token_auth_header(role: @admin_user, is_user: true)) + expect(response.code).to eq("404") + + #Not admin + get("#{edge_creds}/edgy", env: token_auth_header(role: @other_user, is_user: true)) + expect(response.code).to eq("403") + + #Wrong account + get("/edge/edge-creds/tomato/edgy", env: token_auth_header(role: @admin_user, is_user: true)) + expect(response.code).to eq("403") + end + end +end + diff --git a/spec/controllers/edge/api/edge_visibility_controller_spec.rb b/spec/controllers/edge/api/edge_visibility_controller_spec.rb new file mode 100644 index 0000000000..c076428346 --- /dev/null +++ b/spec/controllers/edge/api/edge_visibility_controller_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EdgeVisibilityController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} + let(:admin_user_id) {"#{account}:user:admin_user"} + + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: host_id) + @admin_user = Role.find_or_create(role_id: admin_user_id) + end + + let(:list_edges) do + "/edge/#{account}" + end + + let(:report_edge) do + "/edge/data/#{account}" + end + + context "Visibility" do + before do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + EdgeHandlerController.logger = logger + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + end + + it "List endpoint works" do + # Add some more edges + Edge.new_edge(name: "hedge", id: 7777) + Edge.new_edge(name: "grudge", id: 8888) + Edge.new_edge(name: "fudge", id: 9999) + + get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) + + expect(response.code).to eq("200") + resp = JSON.parse(response.body) + expect(resp.size).to eq(4) + expect(resp[0]['name']).to eq('edgy') + expect(resp[0]['last_sync']).to eq(222222222) + expect(resp[0]['version']).to eq("1.1.1") + expect(resp[0]['platform']).to eq("podman") + + expect(resp[1]['name']).to eq('fudge') + expect(resp[2]['name']).to eq('grudge') + expect(resp[3]['name']).to eq('hedge') + end + + it "Reported data appears on list" do + edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + + get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) + expect(response.code).to eq("200") + resp = JSON.parse(response.body) + expect(resp.size).to eq(1) + expect(resp[0]['last_sync']).to eq(222222222) + expect(resp[0]['version']).to eq("1.1.1") + expect(resp[0]['platform']).to eq("podman") + end + + it "Report invalid data" do + missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': missing_optional}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + + missing_required = '{"edge_statistics": {}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': missing_required}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("422") + end + + it "Report works even without installation" do + edgy = Edge["1234"] + edgy.update(installation_date: nil) + + edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + edgy = Edge["1234"] + expect(edgy.installation_date).to eq(Time.at(-1)) + end + end +end diff --git a/spec/controllers/edge/internal/edge_handler_controller_spec.rb b/spec/controllers/edge/internal/edge_handler_controller_spec.rb new file mode 100644 index 0000000000..11db14382f --- /dev/null +++ b/spec/controllers/edge/internal/edge_handler_controller_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EdgeHandlerController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} + let(:admin_user_id) {"#{account}:user:admin_user"} + + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: host_id) + @admin_user = Role.find_or_create(role_id: admin_user_id) + end + + let(:report_edge) do + "/edge/data/#{account}" + end + + context "Visibility" do + before do + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) + EdgeHandlerController.logger = logger + Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") + RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) + end + + it "Report install data endpoint works" do + edge_details = '{"installation_date": 111111111}' + post("#{report_edge}?data_type=install", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + + expect(response.code).to eq("204") + db_edgy = Edge.where(name: "edgy").first + expect(db_edgy.installation_date.to_i).to eq(111111111) + end + + it "Report ongoing data endpoint works" do + edge_details = '{"edge_statistics": {"last_synch_time": 222222222, "cycle_requests": { + "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, + "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + + expect(response.code).to eq("204") + db_edgy = Edge.where(name: "edgy").first + expect(db_edgy.last_sync.to_i).to eq(222222222) + expect(db_edgy.version).to eq("1.1.1") + expect(db_edgy.platform).to eq("podman") + output = log_output.string + expect(output).to include("EdgeTelemetry") + %w[edgy 123 234 345 456].each {|arg| expect(output).to include(arg)} + end + + it "Report invalid data" do + missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': missing_optional}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + + missing_required = '{"edge_statistics": {}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': missing_required}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("422") + end + + it "Report works even without installation" do + edgy = Edge["1234"] + edgy.update(installation_date: nil) + + edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + expect(response.code).to eq("204") + edgy = Edge["1234"] + expect(edgy.installation_date).to eq(Time.at(-1)) + end + end +end diff --git a/spec/controllers/edge/internal/edge_hosts_controller_spec.rb b/spec/controllers/edge/internal/edge_hosts_controller_spec.rb new file mode 100644 index 0000000000..b7382fc574 --- /dev/null +++ b/spec/controllers/edge/internal/edge_hosts_controller_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe EdgeHostsController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} + let(:other_host_id) {"#{account}:host:data/other"} + + let(:get_hosts) do + "/edge/hosts/#{account}" + end + + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: host_id) + @other_user = Role.find_or_create(role_id: other_host_id) + #@admin_user = Role.find_or_create(role_id: admin_user_id) + end + + context "Host" do + it "Check HMAC" do + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + get(get_hosts, env: token_auth_header(role: @current_user, is_user: false)) + expect(response.code).to eq("200") + expect(response).to be_ok + expect(response.body).to include("api_key".strip) + expect(response.body).to include("salt".strip) + @result = JSON.parse(response.body) + encoded_api_key = @result['hosts'][0]['api_key'] + encoded_salt = @result['hosts'][0]['salt'] + salt = Base64.strict_decode64(encoded_salt) + test_api_key = Base64.strict_encode64(Cryptography.hmac_api_key(@other_user.credentials.api_key, salt)) + expect(test_api_key).to eq(encoded_api_key) + end + end +end \ No newline at end of file diff --git a/spec/controllers/edge/internal/edge_slosilo_keys_controller_spec.rb b/spec/controllers/edge/internal/edge_slosilo_keys_controller_spec.rb new file mode 100644 index 0000000000..8ab500e6db --- /dev/null +++ b/spec/controllers/edge/internal/edge_slosilo_keys_controller_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EdgeSlosiloKeysController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} + + let(:init_prev_key) do + Slosilo[token_id(account, "host", "previous")] ||= Slosilo::Key.new + end + + let(:update_slosilo_keys_url) do + "/edge/slosilo_keys/#{account}" + end + + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: host_id) + end + + def send_request_with_correct_role + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) + expect(response.code).to eq("200") + end + + context "slosilo keys in DB" do + it "Host and Role are correct, previous key is empty" do + send_request_with_correct_role + #get the Slosilo key from DB + key = token_key(account, "host") + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + + expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}], "previousSlosiloKeys" => []} + response_json = JSON.parse(response.body) + expect(response_json).to eq(expected) + end + + it "Host and Role are correct, previous key exist in db" do + init_prev_key + send_request_with_correct_role + #get the Slosilo key from DB + key = token_key(account, "host") + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + #get prev Slosilo key from DB + prev_key = token_key(account, "host", "previous") + prev_private_key = prev_key.to_der.unpack("H*")[0] + prev_fingerprint = prev_key.fingerprint + + expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}], "previousSlosiloKeys" => [{"privateKey"=> prev_private_key,"fingerprint"=>prev_fingerprint}]} + response_json = JSON.parse(response.body) + expect(response_json).to eq(expected) + end + + it "Host is Edge but no Role exists at all" do + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) + expect(response.code).to eq("403") + end + + it "Host is Edge but the host is member in wrong role" do + #add edge-hosts to edge2/edge-hosts group + Role.create(role_id: "#{account}:group:edge2/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge2/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) + expect(response.code).to eq("403") + end + end +end \ No newline at end of file diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb deleted file mode 100644 index 17e45cf78f..0000000000 --- a/spec/controllers/edge_controller_spec.rb +++ /dev/null @@ -1,247 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe EdgeController, :type => :request do - let(:account) { "rspec" } - let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} - let(:other_host_id) {"#{account}:host:data/other"} - let(:admin_user_id) {"#{account}:user:admin_user"} - - let(:log_output) { StringIO.new } - let(:logger) { Logger.new(log_output) } - - before do - init_slosilo_keys(account) - @current_user = Role.find_or_create(role_id: host_id) - @other_user = Role.find_or_create(role_id: other_host_id) - @admin_user = Role.find_or_create(role_id: admin_user_id) - end - - let(:update_slosilo_keys_url) do - "/edge/slosilo_keys/#{account}" - end - - let(:get_hosts) do - "/edge/hosts/#{account}" - end - - let(:list_edges) do - "/edge/#{account}" - end - - let(:report_edge) do - "/edge/data/#{account}" - end - - let(:edge_creds) do - "/edge/edge-creds/#{account}" - end - - - let(:init_prev_key) do - Slosilo[token_id(account, "host", "previous")] ||= Slosilo::Key.new - end - - def send_request_with_correct_role - #add edge-hosts to edge/edge-hosts group - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) - expect(response.code).to eq("200") - end - - context "slosilo keys in DB" do - it "Host and Role are correct, previous key is empty" do - send_request_with_correct_role - #get the Slosilo key from DB - key = token_key(account, "host") - private_key = key.to_der.unpack("H*")[0] - fingerprint = key.fingerprint - - expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}], "previousSlosiloKeys" => []} - response_json = JSON.parse(response.body) - expect(response_json).to eq(expected) - end - - it "Host and Role are correct, previous key exist in db" do - init_prev_key - send_request_with_correct_role - #get the Slosilo key from DB - key = token_key(account, "host") - private_key = key.to_der.unpack("H*")[0] - fingerprint = key.fingerprint - #get prev Slosilo key from DB - prev_key = token_key(account, "host", "previous") - prev_private_key = prev_key.to_der.unpack("H*")[0] - prev_fingerprint = prev_key.fingerprint - - expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}], "previousSlosiloKeys" => [{"privateKey"=> prev_private_key,"fingerprint"=>prev_fingerprint}]} - response_json = JSON.parse(response.body) - expect(response_json).to eq(expected) - end - - it "Host is Edge but no Role exists at all" do - #get the Slosilo key the URL request - get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) - expect(response.code).to eq("403") - end - - it "Host is Edge but the host is member in wrong role" do - #add edge-hosts to edge2/edge-hosts group - Role.create(role_id: "#{account}:group:edge2/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge2/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - - #get the Slosilo key the URL request - get(update_slosilo_keys_url, env: token_auth_header(role: @current_user, is_user: false)) - expect(response.code).to eq("403") - end - end - - context "Host" do - it "Check HMAC" do - #add edge-hosts to edge/edge-hosts group - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - get(get_hosts, env: token_auth_header(role: @current_user, is_user: false)) - expect(response.code).to eq("200") - expect(response).to be_ok - expect(response.body).to include("api_key".strip) - expect(response.body).to include("salt".strip) - @result = JSON.parse(response.body) - encoded_api_key = @result['hosts'][0]['api_key'] - encoded_salt = @result['hosts'][0]['salt'] - salt = Base64.strict_decode64(encoded_salt) - test_api_key = Base64.strict_encode64(Cryptography.hmac_api_key(@other_user.credentials.api_key, salt)) - expect(test_api_key).to eq(encoded_api_key) - end - end - - context "Installation" do - before do - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) - Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") - RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) - end - - it "Generate script error cases" do - #Missing edge - get("#{edge_creds}/non-existent", env: token_auth_header(role: @admin_user, is_user: true)) - expect(response.code).to eq("404") - - #Not admin - get("#{edge_creds}/edgy", env: token_auth_header(role: @other_user, is_user: true)) - expect(response.code).to eq("403") - - #Wrong account - get("/edge/edge-creds/tomato/edgy", env: token_auth_header(role: @admin_user, is_user: true)) - expect(response.code).to eq("403") - end - end - - context "Visibility" do - before do - Role.create(role_id: "#{account}:group:edge/edge-hosts") - RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) - Edge.new_edge(name: "edgy", id: 1234, version: "1.1.1", platform: "podman", installation_date: Time.at(111111111), last_sync: Time.at(222222222)) - EdgeController.logger = logger - Role.create(role_id: "#{account}:group:Conjur_Cloud_Admins") - RoleMembership.create(role_id: "#{account}:group:Conjur_Cloud_Admins", member_id: admin_user_id, admin_option: false, ownership:false) - end - - it "Report install data endpoint works" do - edge_details = '{"installation_date": 111111111}' - post("#{report_edge}?data_type=install", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': edge_details}) - .merge({'CONTENT_TYPE': 'application/json'})) - - expect(response.code).to eq("204") - db_edgy = Edge.where(name: "edgy").first - expect(db_edgy.installation_date.to_i).to eq(111111111) - end - - it "Report ongoing data endpoint works" do - edge_details = '{"edge_statistics": {"last_synch_time": 222222222, "cycle_requests": { - "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, - "edge_version": "1.1.1", "edge_container_type": "podman"}' - post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': edge_details}) - .merge({'CONTENT_TYPE': 'application/json'})) - - expect(response.code).to eq("204") - db_edgy = Edge.where(name: "edgy").first - expect(db_edgy.last_sync.to_i).to eq(222222222) - expect(db_edgy.version).to eq("1.1.1") - expect(db_edgy.platform).to eq("podman") - output = log_output.string - expect(output).to include("EdgeTelemetry") - %w[edgy 123 234 345 456].each {|arg| expect(output).to include(arg)} - end - - it "List endpoint works" do - # Add some more edges - Edge.new_edge(name: "hedge", id: 7777) - Edge.new_edge(name: "grudge", id: 8888) - Edge.new_edge(name: "fudge", id: 9999) - - get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) - - expect(response.code).to eq("200") - resp = JSON.parse(response.body) - expect(resp.size).to eq(4) - expect(resp[0]['name']).to eq('edgy') - expect(resp[0]['last_sync']).to eq(222222222) - expect(resp[0]['version']).to eq("1.1.1") - expect(resp[0]['platform']).to eq("podman") - - expect(resp[1]['name']).to eq('fudge') - expect(resp[2]['name']).to eq('grudge') - expect(resp[3]['name']).to eq('hedge') - end - - it "Reported data appears on list" do - edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' - post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': edge_details}) - .merge({'CONTENT_TYPE': 'application/json'})) - expect(response.code).to eq("204") - - get(list_edges, env: token_auth_header(role: @admin_user, is_user: true)) - expect(response.code).to eq("200") - resp = JSON.parse(response.body) - expect(resp.size).to eq(1) - expect(resp[0]['last_sync']).to eq(222222222) - expect(resp[0]['version']).to eq("1.1.1") - expect(resp[0]['platform']).to eq("podman") - end - - it "Report invalid data" do - missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1"}' - post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': missing_optional}) - .merge({'CONTENT_TYPE': 'application/json'})) - expect(response.code).to eq("204") - - missing_required = '{"edge_statistics": {}, "edge_version": "1.1.1", "edge_container_type": "podman"}' - post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': missing_required}) - .merge({'CONTENT_TYPE': 'application/json'})) - expect(response.code).to eq("422") - end - - it "Report works even without installation" do - edgy = Edge["1234"] - edgy.update(installation_date: nil) - - edge_details = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1", "edge_container_type": "podman"}' - post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': edge_details}) - .merge({'CONTENT_TYPE': 'application/json'})) - expect(response.code).to eq("204") - edgy = Edge["1234"] - expect(edgy.installation_date).to eq(Time.at(-1)) - end - end -end diff --git a/spec/controllers/edge_creator_controller_spec.rb b/spec/controllers/edge_creator_controller_spec.rb deleted file mode 100644 index 5122ecf144..0000000000 --- a/spec/controllers/edge_creator_controller_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -describe EdgeCreatorController, :type => :request do - - context "Edge name validation" do - subject{ EdgeCreatorController.new } - - it "Edge names are validated" do - expect { subject.send(:validate_name, "Edgy") }.to_not raise_error - expect { subject.send(:validate_name, "Edgy_05") }.to_not raise_error - - expect { subject.send(:validate_name, nil) }.to raise_error - expect { subject.send(:validate_name, "") }.to raise_error - expect { subject.send(:validate_name, "Edgy!") }.to raise_error - expect { subject.send(:validate_name, "SuperExtremelyLongEdgeName") }.to raise_error - end - end -end - From a5c49ac2e11cd43351cf40fff3f378bd981987c7 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Wed, 23 Aug 2023 15:34:29 +0300 Subject: [PATCH 090/665] ONYX-43941: Refactor edge endpoint cucumber tests to split to dedicated files --- .../edge/api/edge_configuration.feature | 91 +++++++ .../{ => edge/api}/edge_create.feature | 75 ++---- .../features/edge/api/edge_visibility.feature | 45 ++++ .../edge/internal/edge_handler.feature | 87 +++++++ .../features/edge/internal/edge_hosts.feature | 152 ++++++++++++ .../internal/edge_secrets.feature} | 229 +----------------- .../edge/internal/edge_slosilo_keys.feature | 71 ++++++ 7 files changed, 472 insertions(+), 278 deletions(-) create mode 100644 cucumber/api/features/edge/api/edge_configuration.feature rename cucumber/api/features/{ => edge/api}/edge_create.feature (79%) create mode 100644 cucumber/api/features/edge/api/edge_visibility.feature create mode 100644 cucumber/api/features/edge/internal/edge_handler.feature create mode 100644 cucumber/api/features/edge/internal/edge_hosts.feature rename cucumber/api/features/{edge.feature => edge/internal/edge_secrets.feature} (64%) create mode 100644 cucumber/api/features/edge/internal/edge_slosilo_keys.feature diff --git a/cucumber/api/features/edge/api/edge_configuration.feature b/cucumber/api/features/edge/api/edge_configuration.feature new file mode 100644 index 0000000000..4e61c885a0 --- /dev/null +++ b/cucumber/api/features/edge/api/edge_configuration.feature @@ -0,0 +1,91 @@ +@api +Feature: Fetching edge configuration from edge endpoint + + Background: + Given I create a new user "some_user" + And I create a new user "admin_user" + When I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + + - !group Conjur_Cloud_Admins + - !grant + role: !group Conjur_Cloud_Admins + member: !user admin_user + """ + And I add the secret value "0" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" + And I log out + + @negative @acceptance + Scenario: max edges allowed is permitted only to admins + Given I login as "admin_user" + When I GET "/edge/max-allowed/cucumber" + Then the HTTP response status code is 200 + Given I login as "some_user" + When I GET "/edge/max-allowed/cucumber" + Then the HTTP response status code is 403 + + @acceptance + Scenario: max edges allowed get zero value response + Given I login as "admin_user" + When I GET "/edge/max-allowed/cucumber" + Then the text result is: + """ + 0 + """ + + @acceptance + Scenario: max edges allowed get the secret after a value update as admin + Given I add the secret value "100" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" + And I login as "admin_user" + When I GET "/edge/max-allowed/cucumber" + Then the text result is: + """ + 100 + """ + + @acceptance + Scenario: admin user and other users don't have permission to read max-allowed-edges variable without the endpoint + # All users don't have any permission - so they can't get the variable + Given I login as "admin_user" + When I GET "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" + Then the HTTP response status code is 404 + Given I login as "some_user" + When I GET "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" + Then the HTTP response status code is 404 + + @acceptance + Scenario: admin user and other users don't have permission to change max-allowed-edges variable + # All users don't have any permission - so they can't set the variable + Given I login as "admin_user" + When I POST "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" with body: + """ + v-1 + """ + Then the HTTP response status code is 404 + Given I login as "some_user" + When I POST "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" with body: + """ + v-1 + """ + Then the HTTP response status code is 404 \ No newline at end of file diff --git a/cucumber/api/features/edge_create.feature b/cucumber/api/features/edge/api/edge_create.feature similarity index 79% rename from cucumber/api/features/edge_create.feature rename to cucumber/api/features/edge/api/edge_create.feature index d5e1017dfd..d07040f1d8 100644 --- a/cucumber/api/features/edge_create.feature +++ b/cucumber/api/features/edge/api/edge_create.feature @@ -81,6 +81,7 @@ Feature: Create edge process [action@43868 result="failure" operation="create"] User cucumber:user:admin_user failed to create new Edge instance named edgy """ + @negative @acceptance Scenario: Create edge with non admin_user return 403 Given I login as "host/data/some_host1" @@ -179,57 +180,31 @@ Feature: Create edge process """ @acceptance - Scenario: Edge start report success emits audit + Scenario: Create edge with existing name with capital letters is created Given I login as "admin_user" - And I set the "Content-Type" header to "application/json" - And I POST "/edge/cucumber" with body: - """ - { - "edge_name": "edgy" - } - """ - And the HTTP response status code is 201 And I save my place in the audit log file for remote - When I login as the host associated with Edge "edgy" - And I POST "/edge/data/cucumber?data_type=install" with body: - """ - { "installation_date" : 1111111 } - """ - Then the HTTP response status code is 204 - And there is an audit record matching: - """ - <85>1 * * conjur * installed - [auth@43868 user="edgy"] - [subject@43868 edge="edgy"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="success" operation="install"] - Edge instance edgy has been installed - """ - - @negative - Scenario: Edge start report failure emits audit - Given I login as "admin_user" And I set the "Content-Type" header to "application/json" - And I POST "/edge/cucumber" with body: - """ - { - "edge_name": "edgy" - } - """ - And the HTTP response status code is 201 - And I save my place in the audit log file for remote - When I login as the host associated with Edge "edgy" - And I POST "/edge/data/cucumber?data_type=install" with body: - """ - { "installation_bad_date" : 1111111 } - """ - Then the HTTP response status code is 422 + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ + Then the HTTP response status code is 201 + When I POST "/edge/cucumber" with body: + """ + { + "edge_name": "Edgy" + } + """ + Then the HTTP response status code is 201 + And Edge name "Edgy" data exists in db And there is an audit record matching: - """ - <85>1 * * conjur * installed - [auth@43868 user="edgy"] - [subject@43868 edge="edgy"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="failure" operation="install"] - Edge instance edgy install failed - """ \ No newline at end of file + """ + <85>1 * * conjur * created + [auth@43868 user="cucumber:user:admin_user"] + [subject@43868 edge="Edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="create"] + User cucumber:user:admin_user successfully created new Edge instance named Edgy + """ \ No newline at end of file diff --git a/cucumber/api/features/edge/api/edge_visibility.feature b/cucumber/api/features/edge/api/edge_visibility.feature new file mode 100644 index 0000000000..019330b52b --- /dev/null +++ b/cucumber/api/features/edge/api/edge_visibility.feature @@ -0,0 +1,45 @@ +@api +Feature: Fetching all edges from edge endpoint + + Background: + Given I create a new user "some_user" + And I create a new user "admin_user" + When I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + + - !group Conjur_Cloud_Admins + - !grant + role: !group Conjur_Cloud_Admins + member: !user admin_user + """ + And I log out + + @negative @acceptance + Scenario: List edges permitted only to admins + Given I login as "admin_user" + When I GET "/edge/cucumber" + Then the HTTP response status code is 200 + Given I login as "some_user" + When I GET "/edge/cucumber" + Then the HTTP response status code is 403 \ No newline at end of file diff --git a/cucumber/api/features/edge/internal/edge_handler.feature b/cucumber/api/features/edge/internal/edge_handler.feature new file mode 100644 index 0000000000..f64ed747f0 --- /dev/null +++ b/cucumber/api/features/edge/internal/edge_handler.feature @@ -0,0 +1,87 @@ +Feature: Edge data endpoint + + Background: + Given I create a new user "some_user" + And I create a new user "admin_user" + And I have host "data/some_host1" + And I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !group edge-installer-group + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !variable edge-cycle-interval + - !permit + role: !group edge-hosts + privileges: [ read, execute ] + resources: !variable edge-configuration/edge-cycle-interval + + - !group Conjur_Cloud_Admins + - !grant + role: !group Conjur_Cloud_Admins + member: !user admin_user + """ + And I add the secret value "3" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" + + @acceptance + Scenario: Edge start report success emits audit + Given I login as "admin_user" + And I set the "Content-Type" header to "application/json" + And I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ + And the HTTP response status code is 201 + And I save my place in the audit log file for remote + When I login as the host associated with Edge "edgy" + And I POST "/edge/data/cucumber?data_type=install" with body: + """ + { "installation_date" : 1111111 } + """ + Then the HTTP response status code is 204 + And there is an audit record matching: + """ + <85>1 * * conjur * installed + [auth@43868 user="edgy"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="install"] + Edge instance edgy has been installed + """ + + @negative + Scenario: Edge start report failure emits audit + Given I login as "admin_user" + And I set the "Content-Type" header to "application/json" + And I POST "/edge/cucumber" with body: + """ + { + "edge_name": "edgy" + } + """ + And the HTTP response status code is 201 + And I save my place in the audit log file for remote + When I login as the host associated with Edge "edgy" + And I POST "/edge/data/cucumber?data_type=install" with body: + """ + { "installation_bad_date" : 1111111 } + """ + Then the HTTP response status code is 422 + And there is an audit record matching: + """ + <85>1 * * conjur * installed + [auth@43868 user="edgy"] + [subject@43868 edge="edgy"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="install"] + Edge instance edgy install failed + """ \ No newline at end of file diff --git a/cucumber/api/features/edge/internal/edge_hosts.feature b/cucumber/api/features/edge/internal/edge_hosts.feature new file mode 100644 index 0000000000..4555d393c6 --- /dev/null +++ b/cucumber/api/features/edge/internal/edge_hosts.feature @@ -0,0 +1,152 @@ +@api +Feature: Fetching host from edge endpoint + + Background: + Given I create a new user "some_user" + And I have host "data/some_host2" + And I have host "data/some_host3" + And I have host "data/some_host4" + And I have host "data/some_host5" + And I have host "other_host1" + And I have host "database/other_host2" + And I am the super-user + When I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + + - !policy + id: data + body: + - !host + id: some_host1 + annotations: + authn/api-key: true + test2: test1 + test: + """ + And I log out + + @acceptance @smoke + Scenario: Fetching hosts with edge host return 200 OK + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 200 + And the JSON response at "hosts" should have 5 entries + And the JSON response should not have "database" + And the JSON response should not have "other_host" + And the JSON at "hosts/0/annotations" should include: + """ + {"name": "test2", "value": "test1"}, {"name": "test", "value": ""}, {"name": "authn/api-key", "value": "true"} + """ + And the JSON at "hosts/1/annotations" should be: + """ + [] + """ + + @acceptance + Scenario: Fetching hosts with parameters + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 10 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 3 entries + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" with parameters: + """ + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 3 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 5 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2000 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 0 + """ + Then the HTTP response status code is 422 + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2001 + """ + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching hosts count + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/hosts/cucumber?count=true" + Then I receive a count of 5 + + @acceptance + Scenario: Fetching hosts count with limit has no effect + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/hosts/cucumber?count=true&limit=2&offset=0" + Then I receive a count of 5 + + + @negative @acceptance + Scenario: Fetching hosts with non edge host return 403 + + Given I login as "some_user" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge/internal/edge_secrets.feature similarity index 64% rename from cucumber/api/features/edge.feature rename to cucumber/api/features/edge/internal/edge_secrets.feature index d92563b0ba..75542b3903 100644 --- a/cucumber/api/features/edge.feature +++ b/cucumber/api/features/edge/internal/edge_secrets.feature @@ -3,16 +3,11 @@ Feature: Fetching secrets from edge endpoint Background: Given I create a new user "some_user" - And I create a new user "admin_user" And I have host "data/some_host2" And I have host "data/some_host3" And I have host "data/some_host4" - And I have host "data/some_host5" - And I have host "other_host1" - And I have host "database/other_host2" - And I have a "variable" resource called "other_sec" And I am the super-user - And I successfully PUT "/policies/cucumber/policy/root" with body: + When I successfully PUT "/policies/cucumber/policy/root" with body: """ - !policy id: edge @@ -48,8 +43,6 @@ Feature: Fetching secrets from edge endpoint id: some_host1 annotations: authn/api-key: true - test2: test1 - test: - !permit role: !host some_host1 privilege: [ execute ] @@ -66,57 +59,14 @@ Feature: Fetching secrets from edge endpoint role: !host some_host4 privilege: [ write ] resource: !variable secret1 - - !group Conjur_Cloud_Admins - - !grant - role: !group Conjur_Cloud_Admins - member: !user admin_user """ And I add the secret value "s1" to the resource "cucumber:variable:data/secret1" And I add the secret value "s2" to the resource "cucumber:variable:data/secret2" And I add the secret value "s3" to the resource "cucumber:variable:data/secret3" And I add the secret value "s4" to the resource "cucumber:variable:data/secret4" And I add the secret value "s5" to the resource "cucumber:variable:data/secret5" - # secret6 has no value on purpose. Endpoint `all_secrets` should not return it - And I add the secret value "0" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" And I log out - # Slosilo key - ######### - @acceptance - Scenario: Fetching key with edge host return 200 OK with json result - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I GET "/edge/slosilo_keys/cucumber" - Then the HTTP response status code is 200 - And the JSON at "slosiloKeys" should have 1 entries - And the JSON should have "slosiloKeys/0/fingerprint" - And the JSON at "slosiloKeys/0/fingerprint" should be a string - And the JSON should have "slosiloKeys/0/privateKey" - And the JSON at "slosiloKeys/0/privateKey" should be a string - And the JSON at "previousSlosiloKeys" should have 1 entries - And the JSON at "previousSlosiloKeys/0/fingerprint" should be a string - And the JSON should have "previousSlosiloKeys/0/privateKey" - And the JSON at "previousSlosiloKeys/0/privateKey" should be a string - - @negative @acceptance - Scenario: Fetching hosts with non edge host return 403 - Given I login as "some_user" - When I GET "/edge/slosilo_keys/cucumber" - Then the HTTP response status code is 403 - Given I login as "host/data/some_host1" - When I GET "/edge/slosilo_keys/cucumber" - Then the HTTP response status code is 403 - Given I am the super-user - When I GET "/edge/slosilo_keys/cucumber" - Then the HTTP response status code is 403 - #test wrong account name - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I GET "/edge/slosilo_keys/cucumber2" - Then the HTTP response status code is 403 - - - # Secrets - ######### - @acceptance @smoke Scenario: Fetching all secrets with edge host return 200 OK with json results @@ -571,180 +521,3 @@ Feature: Fetching secrets from edge endpoint ], "failed":[]} """ - - # Hosts - ####### - - @acceptance @smoke - Scenario: Fetching hosts with edge host return 200 OK - - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I GET "/edge/hosts/cucumber" - Then the HTTP response status code is 200 - And the JSON response at "hosts" should have 5 entries - And the JSON response should not have "database" - And the JSON response should not have "other_host" - And the JSON at "hosts/0/annotations" should include: - """ - {"name": "test2", "value": "test1"}, {"name": "test", "value": ""}, {"name": "authn/api-key", "value": "true"} - """ - And the JSON at "hosts/1/annotations" should be: - """ - [] - """ - - - @acceptance - Scenario: Fetching hosts with parameters - - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 2 - offset: 0 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 2 entries - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 10 - offset: 2 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 3 entries - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I GET "/edge/hosts/cucumber" with parameters: - """ - offset: 0 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 5 entries - When I GET "/edge/hosts/cucumber" with parameters: - """ - offset: 2 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 3 entries - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 2 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 2 entries - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 5 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 5 entries - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 2000 - """ - Then the HTTP response status code is 200 - And the JSON at "hosts" should have 5 entries - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 0 - """ - Then the HTTP response status code is 422 - When I GET "/edge/hosts/cucumber" with parameters: - """ - limit: 2001 - """ - Then the HTTP response status code is 422 - - @acceptance - Scenario: Fetching hosts count - - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I successfully GET "/edge/hosts/cucumber?count=true" - Then I receive a count of 5 - - @acceptance - Scenario: Fetching hosts count with limit has no effect - - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - When I successfully GET "/edge/hosts/cucumber?count=true&limit=2&offset=0" - Then I receive a count of 5 - - - @negative @acceptance - Scenario: Fetching hosts with non edge host return 403 - - Given I login as "some_user" - When I GET "/edge/hosts/cucumber" - Then the HTTP response status code is 403 - Given I login as "host/data/some_host1" - When I GET "/edge/hosts/cucumber" - Then the HTTP response status code is 403 - Given I am the super-user - When I GET "/edge/hosts/cucumber" - Then the HTTP response status code is 403 - - @negative @acceptance - Scenario: List edges permitted only to admins - Given I login as "admin_user" - When I GET "/edge/cucumber" - Then the HTTP response status code is 200 - Given I login as "some_user" - When I GET "/edge/cucumber" - Then the HTTP response status code is 403 - - ###################### - # Max edges allowed - ###################### - - @negative @acceptance - Scenario: max edges allowed is permitted only to admins - Given I login as "admin_user" - When I GET "/edge/max-allowed/cucumber" - Then the HTTP response status code is 200 - Given I login as "some_user" - When I GET "/edge/max-allowed/cucumber" - Then the HTTP response status code is 403 - - @acceptance - Scenario: max edges allowed get zero value response - Given I login as "admin_user" - When I GET "/edge/max-allowed/cucumber" - Then the text result is: - """ - 0 - """ - - @acceptance - Scenario: max edges allowed get the secret after a value update as admin - Given I add the secret value "100" to the resource "cucumber:variable:edge/edge-configuration/max-edge-allowed" - And I login as "admin_user" - When I GET "/edge/max-allowed/cucumber" - Then the text result is: - """ - 100 - """ - - @acceptance - Scenario: admin user and other users don't have permission to read max-allowed-edges variable without the endpoint - # All users don't have any permission - so they can't get the variable - Given I login as "admin_user" - When I GET "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" - Then the HTTP response status code is 404 - Given I login as "some_user" - When I GET "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" - Then the HTTP response status code is 404 - - @acceptance - Scenario: admin user and other users don't have permission to change max-allowed-edges variable - # All users don't have any permission - so they can't set the variable - Given I login as "admin_user" - When I POST "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" with body: - """ - v-1 - """ - Then the HTTP response status code is 404 - Given I login as "some_user" - When I POST "/secrets/cucumber/variable/edge/edge-configuration/max-edge-allowed" with body: - """ - v-1 - """ - Then the HTTP response status code is 404 \ No newline at end of file diff --git a/cucumber/api/features/edge/internal/edge_slosilo_keys.feature b/cucumber/api/features/edge/internal/edge_slosilo_keys.feature new file mode 100644 index 0000000000..c577aceff7 --- /dev/null +++ b/cucumber/api/features/edge/internal/edge_slosilo_keys.feature @@ -0,0 +1,71 @@ +@api +Feature: Fetching slosilo keys from edge endpoint + + Background: + Given I create a new user "some_user" + And I am the super-user + When I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + + - !policy + id: data + body: + - !host + id: some_host1 + annotations: + authn/api-key: true + + - !group Conjur_Cloud_Admins + """ + And I log out + + @acceptance + Scenario: Fetching key with edge host return 200 OK with json result + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 200 + And the JSON at "slosiloKeys" should have 1 entries + And the JSON should have "slosiloKeys/0/fingerprint" + And the JSON at "slosiloKeys/0/fingerprint" should be a string + And the JSON should have "slosiloKeys/0/privateKey" + And the JSON at "slosiloKeys/0/privateKey" should be a string + And the JSON at "previousSlosiloKeys" should have 1 entries + And the JSON at "previousSlosiloKeys/0/fingerprint" should be a string + And the JSON should have "previousSlosiloKeys/0/privateKey" + And the JSON at "previousSlosiloKeys/0/privateKey" should be a string + + @negative @acceptance + Scenario: Fetching hosts with non edge host return 403 + Given I login as "some_user" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + #test wrong account name + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/slosilo_keys/cucumber2" + Then the HTTP response status code is 403 From 87700a6a723f13918b997ebf8ce715d687806278 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 23 Aug 2023 11:11:08 +0300 Subject: [PATCH 091/665] ONYX-44037 - Add to the Edge ongoing log the tenant id --- .../data_handlers/ongoing_handler.rb | 19 ++++++++++++-- app/domain/logs.rb | 7 ++--- .../conjur_ephemeral_engine_client.rb | 5 +--- lib/conjur/conjur_config.rb | 15 +++++++++-- .../internal/edge_handler_controller_spec.rb | 26 ++++++++++++++++--- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/app/domain/edge_logic/data_handlers/ongoing_handler.rb b/app/domain/edge_logic/data_handlers/ongoing_handler.rb index b92238f1b3..70ba8cc678 100644 --- a/app/domain/edge_logic/data_handlers/ongoing_handler.rb +++ b/app/domain/edge_logic/data_handlers/ongoing_handler.rb @@ -22,10 +22,13 @@ def call(params, hostname, ip) # Log Edge statistics to be collected by Datadog stats = options['edge_statistics'] cycle_reqs = stats['cycle_requests'] || {} - @logger.info(LogMessages::Edge::EdgeTelemetry.new(edge.name, Time.at(stats['last_synch_time']), + #convert time to seconds + last_synch_time_sec = Rational(stats['last_synch_time'], 1000) + installation_time_sec = Rational(edge.installation_date, 1000) + @logger.info(LogMessages::Edge::EdgeTelemetry.new(parsed_tenant_id, edge.name, Time.at(last_synch_time_sec), cycle_reqs['get_secret'], cycle_reqs['apikey_authenticate'], cycle_reqs['jwt_authenticate'], cycle_reqs['redirect'], - edge.version, edge.platform, Time.at(edge.installation_date))) + edge.version, edge.platform, Time.at(installation_time_sec))) end def input_validator @@ -43,6 +46,18 @@ def input_validator } end + private + + def parsed_tenant_id + tenant_id = Rails.application.config.conjur_config.tenant_id + if tenant_id.match?(/\A[a-f0-9]{32}\z/) + # Insert dashes at the specific positions to achieve the tenant-id format + formatted_output = "#{tenant_id[0..7]}-#{tenant_id[8..11]}-#{tenant_id[12..15]}-#{tenant_id[16..19]}-#{tenant_id[20..31]}" + else + "" + end + end + end end end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 98de74c370..4b6c0c965e 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -49,9 +49,10 @@ module Endpoints module Edge EdgeTelemetry = ::Util::TrackableLogMessageClass.new( - msg: "Edge {0} was last synced at {1}. Requests served during last sync interval: " \ - "get secret: {2}, auth apikey: {3}, auth jwt: {4}, redirect {5}." \ - "Edge Info: version: {6}, platform: {7}, install time: {8}", + msg: "Requested from tenant: {0}." \ + "Edge {1} was last synced at {2}. Requests served during last sync interval: " \ + "get_secret={3}, auth_apikey={4}, auth_jwt={5}, redirect={6}." \ + "Edge_Info: version={7}, platform={8}, install_time={9}.", code: "CONJ00158" ) end diff --git a/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb b/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb index a3818a5820..70b95f3805 100644 --- a/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb +++ b/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb @@ -81,9 +81,6 @@ def hash_keys_to_camel_case(hash, level = 0) end def tenant_id - result = ENV["HOSTNAME"] - result.split("-")[1] || "" - rescue - "" + Rails.application.config.conjur_config.tenant_id end end diff --git a/lib/conjur/conjur_config.rb b/lib/conjur/conjur_config.rb index 395b2acc9e..148ce8eb32 100644 --- a/lib/conjur/conjur_config.rb +++ b/lib/conjur/conjur_config.rb @@ -39,7 +39,8 @@ class ConjurConfig < Anyway::Config authn_api_key_default: true, authenticators: [], extensions: [], - slosilo_rotation_interval: 24 # Sloislo rotation should be every 24 hours + slosilo_rotation_interval: 24, # Sloislo rotation should be every 24 hours + tenant_id: @tenant_id ) def initialize( @@ -50,7 +51,7 @@ def initialize( # The permissions checks emit log messages, so we need to initialize the # logger before verifying permissions. @logger = logger - + @tenant_id = tenant_id # First verify that we have the permissions necessary to read the config # file. verify_config_is_readable @@ -121,6 +122,16 @@ def extensions=(val) super(str_to_list(val)&.uniq) end + def tenant_id + #parsing tenant_id from hostname + begin + result = ENV["HOSTNAME"] + result.split("-")[1] || "" + rescue + "" + end + end + private def verify_config_is_readable diff --git a/spec/controllers/edge/internal/edge_handler_controller_spec.rb b/spec/controllers/edge/internal/edge_handler_controller_spec.rb index 11db14382f..6946f1497a 100644 --- a/spec/controllers/edge/internal/edge_handler_controller_spec.rb +++ b/spec/controllers/edge/internal/edge_handler_controller_spec.rb @@ -42,21 +42,41 @@ end it "Report ongoing data endpoint works" do - edge_details = '{"edge_statistics": {"last_synch_time": 222222222, "cycle_requests": { + edge_details = '{"edge_statistics": {"last_synch_time": 1692633684386, "cycle_requests": { "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, "edge_version": "1.1.1", "edge_container_type": "podman"}' + ENV["HOSTNAME"] = " cnj-44da78944cc54bcdb37c316ad40ec8c6-85b9f7d95b-fwfm5" post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': edge_details}) .merge({'CONTENT_TYPE': 'application/json'})) expect(response.code).to eq("204") db_edgy = Edge.where(name: "edgy").first - expect(db_edgy.last_sync.to_i).to eq(222222222) + expect(db_edgy.last_sync.to_i).to eq(1692633684386) expect(db_edgy.version).to eq("1.1.1") expect(db_edgy.platform).to eq("podman") output = log_output.string expect(output).to include("EdgeTelemetry") - %w[edgy 123 234 345 456].each {|arg| expect(output).to include(arg)} + %w[edgy 123 234 345 456 44da7894-4cc5-4bcd-b37c-316ad40ec8c6 2023-08-21].each {|arg| expect(output).to include(arg)} + end + + it "Report ongoing data endpoint works with wrong tenant format" do + edge_details = '{"edge_statistics": {"last_synch_time": 222222223, "cycle_requests": { + "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, + "edge_version": "1.1.2", "edge_container_type": "docker"}' + ENV["HOSTNAME"] = " cnj-44da78944cc54bcdb37c316ad40ec8c-85b9f7d95b-fwfm5" + post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) + .merge({'RAW_POST_DATA': edge_details}) + .merge({'CONTENT_TYPE': 'application/json'})) + + expect(response.code).to eq("204") + db_edgy = Edge.where(name: "edgy").first + expect(db_edgy.last_sync.to_i).to eq(222222223) + expect(db_edgy.version).to eq("1.1.2") + expect(db_edgy.platform).to eq("docker") + output = log_output.string + expect(output).to include("EdgeTelemetry") + %w[edgy 123 234 345 456 1970-01-03].each {|arg| expect(output).to include(arg)} end it "Report invalid data" do From b529000c5e422b7ae4b3a410b1faa995e8b8437a Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Sun, 27 Aug 2023 17:47:32 +0300 Subject: [PATCH 092/665] Renamed platforms to issuers and fixed additional functionality related to the feature --- ...rm_resource.rb => find_issuer_resource.rb} | 8 +- app/controllers/issuers_controller.rb | 226 ++++++++++++++++++ app/controllers/platforms_controller.rb | 221 ----------------- app/controllers/secrets_controller.rb | 29 +-- .../conjur_ephemeral_engine_client.rb | 20 +- .../ephemeral_engine_client.rb | 2 +- .../issuers/issuer_types/aws_issuer_type.rb | 43 ++++ .../issuers/issuer_types/issuer_base_type.rb | 71 ++++++ .../issuer_types/issuer_type_factory.rb | 11 + app/domain/logs.rb | 17 +- .../platform_types/aws_platform_type.rb | 41 ---- .../platform_types/platform_base_type.rb | 66 ----- .../platform_types/platform_type_factory.rb | 11 - .../policy-templates/issuers/create_issuer.rb | 23 ++ .../delete_issuer.rb} | 11 +- .../platforms/create_platform.rb | 48 ---- .../audit/event/{platform.rb => issuer.rb} | 11 +- app/models/audit/event/issuer_variable.rb | 95 ++++++++ app/models/audit/subject.rb | 8 +- app/models/issuer.rb | 47 ++++ app/models/platform.rb | 21 -- config/routes.rb | 8 +- ...0230820152200_rename_platform_to_issuer.rb | 18 ++ 23 files changed, 587 insertions(+), 469 deletions(-) rename app/controllers/concerns/{find_platform_resource.rb => find_issuer_resource.rb} (65%) create mode 100644 app/controllers/issuers_controller.rb delete mode 100644 app/controllers/platforms_controller.rb rename app/domain/{platforms => issuers}/ephemeral_engines/conjur_ephemeral_engine_client.rb (80%) rename app/domain/{platforms => issuers}/ephemeral_engines/ephemeral_engine_client.rb (66%) create mode 100644 app/domain/issuers/issuer_types/aws_issuer_type.rb create mode 100644 app/domain/issuers/issuer_types/issuer_base_type.rb create mode 100644 app/domain/issuers/issuer_types/issuer_type_factory.rb delete mode 100644 app/domain/platforms/platform_types/aws_platform_type.rb delete mode 100644 app/domain/platforms/platform_types/platform_base_type.rb delete mode 100644 app/domain/platforms/platform_types/platform_type_factory.rb create mode 100644 app/domain/policy-templates/issuers/create_issuer.rb rename app/domain/policy-templates/{platforms/delete_platform.rb => issuers/delete_issuer.rb} (55%) delete mode 100644 app/domain/policy-templates/platforms/create_platform.rb rename app/models/audit/event/{platform.rb => issuer.rb} (81%) create mode 100644 app/models/audit/event/issuer_variable.rb create mode 100644 app/models/issuer.rb delete mode 100644 app/models/platform.rb create mode 100644 db/migrate/20230820152200_rename_platform_to_issuer.rb diff --git a/app/controllers/concerns/find_platform_resource.rb b/app/controllers/concerns/find_issuer_resource.rb similarity index 65% rename from app/controllers/concerns/find_platform_resource.rb rename to app/controllers/concerns/find_issuer_resource.rb index 69a51751e8..4ea8de4fe1 100644 --- a/app/controllers/concerns/find_platform_resource.rb +++ b/app/controllers/concerns/find_issuer_resource.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true -module FindPlatformResource +module FindIssuerResource include FindResource + include AccountValidator extend ActiveSupport::Concern def resource_id if request.request_method == "GET" && request.filtered_parameters[:action] == "get" - [ account, "policy", "data/platforms/#{params[:identifier]}" ].join(":") + [ account, "policy", "conjur/issuers/#{params[:identifier]}" ].join(":") else - [ account, "policy", "data/platforms" ].join(":") + [ account, "policy", "conjur/issuers" ].join(":") end end def find_or_create_root_policy + validate_account(account) Loader::Types.find_or_create_root_policy(account) end diff --git a/app/controllers/issuers_controller.rb b/app/controllers/issuers_controller.rb new file mode 100644 index 0000000000..366538dffb --- /dev/null +++ b/app/controllers/issuers_controller.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +require_relative '../controllers/wrappers/templates_renderer' +require_relative '../domain/issuers/issuer_types/issuer_type_factory' +# +class IssuersController < RestController + include AccountValidator + include AuthorizeResource + include PolicyAudit + include PolicyWrapper + include PolicyTemplates::TemplatesRenderer + include BodyParser + include FindIssuerResource + + before_action :current_user + before_action :find_or_create_root_policy + + rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load + + ISSUER_NOT_FOUND = "Issuer not found" + + def create + logger.info(LogMessages::Endpoints::EndpointRequested.new("POST issuers/#{params[:account]}")) + action = :create + authorize(action, resource) + + issuer_type = IssuerTypeFactory.new.create_issuer_type(params[:type]) + issuer_type.validate(body_params) + + issuer = Issuer.new(issuer_id: params[:id], account: params[:account], + issuer_type: params[:type], + max_ttl: params[:max_ttl], data: params[:data].to_json, + modified_at: Sequel::CURRENT_TIMESTAMP, + policy_id: "#{params[:account]}:policy:conjur/issuers/#{params[:id]}") + + raise ApplicationController::InternalServerError, "Found related variable/s to the given issuer id" if issuer.issuer_variables_exist? + + create_issuer_policy({ "id" => params[:id] }) + issuer.save + issuer_audit_success(issuer.account, issuer.issuer_id, "add") + + render(json: issuer.as_json, status: :created) + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("POST issuers/#{params[:account]}")) + rescue Exceptions::RecordNotFound => e + logger.error(LogMessages::Issuers::IssuerEndpointForbidden.new("create")) + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + raise Exceptions::Forbidden, "issuers" + rescue ApplicationController::BadRequest => e + logger.error("Input validation error for issuer [#{params[:id]}]: #{e.message}") + audit_failure(e, action) + render(json: { + error: { + code: "bad_request", + message: e.message + } + }, status: :bad_request) + rescue Sequel::UniqueConstraintViolation => e + logger.error("Issuer [#{params[:id]}] already exists") + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + raise Exceptions::RecordExists.new("issuer", params[:id]) + rescue => e + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + head :internal_server_error + end + + def delete + logger.info(LogMessages::Endpoints::EndpointRequested.new("DELETE issuers/#{params[:account]}/#{params[:identifier]}")) + action = :update + authorize(action, resource) + + issuer = get_issuer_from_db(params[:account], params[:identifier]) + if issuer + # Deleting the issuer policy causes a cascade delete of the issuers object as well + # But we need to delete the issuer related variables so that we won't leave orphans + deleted_variables = issuer.delete_issuer_variables + delete_issuer_policy({ "id" => params[:identifier] }) + issuer_audit_success(issuer.account, issuer.issuer_id, "remove") + issuer_variables_audit_delete(issuer.account, issuer.issuer_id, deleted_variables) + head :ok + else + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + end + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("DELETE issuers/#{params[:account]}/#{params[:identifier]}")) + rescue Exceptions::RecordNotFound => e + logger.error(LogMessages::Issuers::IssuerPolicyNotFound.new(resource_id)) + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:identifier], "remove", e.message) + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + rescue => e + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:identifier], "remove", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + + def get + logger.info(LogMessages::Endpoints::EndpointRequested.new("GET issuers/#{params[:account]}/#{params[:identifier]}")) + # If I can update the issuer policy, it means I am allowed to view it as well + action = :update + authorize(action, resource) + + issuer = get_issuer_from_db(params[:account], params[:identifier]) + if issuer + issuer_audit_success(issuer.account, issuer.issuer_id, "fetch") + render(json: issuer.as_json, status: :ok) + else + # issuer_audit_failure(issuer.account, issuer.issuer_id, "get", ISSUER_NOT_FOUND) + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + end + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}/#{params[:identifier]}")) + rescue Exceptions::RecordNotFound => e + issuer_audit_failure(params[:account], params[:identifier], "fetch", ISSUER_NOT_FOUND) + logger.error(LogMessages::Issuers::IssuerPolicyNotFound.new(resource_id)) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}/#{params[:identifier]}")) + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + rescue => e + issuer_audit_failure(params[:account], params[:identifier], "fetch", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + + def list + logger.info(LogMessages::Endpoints::EndpointRequested.new("GET issuers/#{params[:account]}")) + # If I can update the issuer policy, it means I am allowed to view it as well + action = :update + authorize(action, resource) + + issuers = list_issuers_from_db(params[:account]) + result = [] + issuers.each do |item| + result.push(item.as_json) + end + issuer_audit_success(params[:account], "*", "list") + render(json: { issuers: result }, status: :ok) + + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}")) + rescue Exceptions::RecordNotFound => e + logger.error(LogMessages::Issuers::IssuerEndpointForbidden.new("list")) + issuer_audit_failure(params[:account], "*", "list", e.message) + raise Exceptions::Forbidden, "issuers" + rescue => e + issuer_audit_failure(params[:account], "*", "list", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end +end + +private + +def create_issuer_policy(policy_fields) + result_yaml = renderer(PolicyTemplates::CreateIssuer.new, policy_fields) + set_raw_policy(result_yaml) + result = load_policy(Loader::CreatePolicy, false, resource) + policy = result[:policy] + audit_success(policy) +end + +def delete_issuer_policy(policy_fields) + result_yaml = renderer(PolicyTemplates::DeleteIssuer.new, policy_fields) + set_raw_policy(result_yaml) + result = load_policy(Loader::ModifyPolicy, true, resource) + policy = result[:policy] + audit_success(policy) +end + +def get_issuer_from_db(account, issuer_id) + Issuer.where(account: account, issuer_id: issuer_id).first +end + +def list_issuers_from_db(account) + Issuer.where(account: account).all +end + +def issuer_audit_success(account, issuer_id, operation) + subject = { account: account, issuer: issuer_id } + Audit.logger.log( + Audit::Event::Issuer.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "issuer", + success: true, + operation: operation + ) + ) +end + +def issuer_audit_failure(account, issuer_id, operation, error_message) + subject = { account: account, issuer: issuer_id } + Audit.logger.log( + Audit::Event::Issuer.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "issuer", + success: false, + operation: operation, + error_message: error_message + ) + ) +end + +def issuer_variables_audit_delete(account, issuer_id, deleted_variables) + deleted_variables.each do |variable_id| + subject = { account: account, issuer: issuer_id, resource_id: variable_id } + Audit.logger.log( + Audit::Event::IssuerVariable.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "variable", + success: true, + operation: "remove" + ) + ) + end +end diff --git a/app/controllers/platforms_controller.rb b/app/controllers/platforms_controller.rb deleted file mode 100644 index 4494775bd2..0000000000 --- a/app/controllers/platforms_controller.rb +++ /dev/null @@ -1,221 +0,0 @@ -# frozen_string_literal: true - -require_relative '../controllers/wrappers/policy_wrapper' -require_relative '../controllers/wrappers/policy_audit' -require_relative '../controllers/wrappers/templates_renderer' -require_relative '../domain/platforms/platform_types/platform_type_factory' -# -class PlatformsController < RestController - include AuthorizeResource - include PolicyAudit - include PolicyWrapper - include PolicyTemplates::TemplatesRenderer - include BodyParser - include FindPlatformResource - - before_action :current_user - before_action :find_or_create_root_policy - - rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load - - PLATFORM_NOT_FOUND = "Platform not found" - - def create - logger.info(LogMessages::Endpoints::EndpointRequested.new("POST platforms/#{params[:account]}")) - action = :create - authorize(action, resource) - - platform_type = PlatformTypeFactory.new.create_platform_type(params[:type]) - platform_type.validate(params) - - policy_fields = input_create_yaml(params[:id], platform_type.default_secret_method) - create_platform_policy(policy_fields) - - save_platform(params) - platform = get_platform_from_db(params[:account], params[:id]) - raise ApplicationController::InternalServerError, "There was an error saving the platform" unless platform - platform_audit_success(platform.account, platform.platform_id, "create") - - render(json: platform.as_json, status: :created) - - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("POST platforms/#{params[:account]}")) - rescue Exceptions::RecordNotFound => e - logger.error(LogMessages::Platforms::PlatformEndpointForbidden.new("create")) - audit_failure(e, action) - platform_audit_failure(params[:account], params[:id], "create", e.message) - raise Exceptions::Forbidden, "platforms" - rescue ApplicationController::BadRequest => e - logger.error("Input validation error for platform [#{params[:id]}]: #{e.message}") - audit_failure(e, action) - render(json: { - error: { - code: "bad_request", - message: e.message - } - }, status: :bad_request) - rescue Sequel::UniqueConstraintViolation => e - logger.error("Platform [#{params[:id]}] already exists") - audit_failure(e, action) - platform_audit_failure(params[:account], params[:id], "create", e.message) - raise Exceptions::RecordExists.new("platform", params[:id]) - rescue => e - audit_failure(e, action) - platform_audit_failure(params[:account], params[:id], "create", e.message) - head :internal_server_error - end - - def delete - logger.info(LogMessages::Endpoints::EndpointRequested.new("DELETE platforms/#{params[:account]}/#{params[:identifier]}")) - action = :update - authorize(action, resource) - - platform = get_platform_from_db(params[:account], params[:identifier]) - if platform - policy_fields = input_delete_yaml(params[:identifier]) - delete_platform_policy(policy_fields) - platform_audit_success(platform.account, platform.platform_id, "delete") - # Deleting the platform causes a cascade delete of the record in the platforms table as well - head :ok - else - raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) - end - - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("DELETE platforms/#{params[:account]}/#{params[:identifier]}")) - rescue Exceptions::RecordNotFound => e - logger.error(LogMessages::Platforms::PlatformPolicyNotFound.new(resource_id)) - audit_failure(e, action) - platform_audit_failure(params[:account], params[:identifier], "delete", e.message) - raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) - rescue => e - audit_failure(e, action) - platform_audit_failure(params[:account], params[:identifier], "delete", e.message) - raise e - end - - def get - logger.info(LogMessages::Endpoints::EndpointRequested.new("GET platforms/#{params[:account]}/#{params[:identifier]}")) - # If I can update the platform policy, it means I am allowed to view it as well - action = :update - authorize(action, resource) - - platform = get_platform_from_db(params[:account], params[:identifier]) - if platform - platform_audit_success(platform.account, platform.platform_id, "get") - render(json: platform.as_json, status: :ok) - else - # platform_audit_failure(platform.account, platform.platform_id, "get", PLATFORM_NOT_FOUND) - raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) - end - - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET platforms/#{params[:account]}/#{params[:identifier]}")) - rescue Exceptions::RecordNotFound => e - platform_audit_failure(params[:account], params[:identifier], "get", PLATFORM_NOT_FOUND) - logger.error(LogMessages::Platforms::PlatformPolicyNotFound.new(resource_id)) - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET platforms/#{params[:account]}/#{params[:identifier]}")) - raise Exceptions::RecordNotFound.new(params[:identifier], message: PLATFORM_NOT_FOUND) - rescue => e - platform_audit_failure(params[:account], params[:identifier], "get", e.message) - raise e - end - - def list - logger.info(LogMessages::Endpoints::EndpointRequested.new("GET platforms/#{params[:account]}")) - # If I can update the platform policy, it means I am allowed to view it as well - action = :update - authorize(action, resource) - - platforms = list_platforms_from_db(params[:account]) - result = [] - platforms.each do |item| - result.push(item.as_json) - end - platform_audit_success(params[:account], "*", "list") - render(json: { platforms: result }, status: :ok) - - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET platforms/#{params[:account]}")) - rescue Exceptions::RecordNotFound => e - logger.error(LogMessages::Platforms::PlatformEndpointForbidden.new("list")) - platform_audit_failure(params[:account], "*", "list", e.message) - raise Exceptions::Forbidden, "platforms" - rescue => e - platform_audit_failure(params[:account], "*", "list", e.message) - raise e - end -end - -private - -def create_platform_policy(policy_fields) - result_yaml = renderer(PolicyTemplates::CreatePlatform.new(), policy_fields) - set_raw_policy(result_yaml) - result = load_policy(Loader::CreatePolicy, false, resource) - policy = result[:policy] - audit_success(policy) -end - -def delete_platform_policy(policy_fields) - result_yaml = renderer(PolicyTemplates::DeletePlatform.new(), policy_fields) - set_raw_policy(result_yaml) - result = load_policy(Loader::ModifyPolicy, true, resource) - policy = result[:policy] - audit_success(policy) -end - -def save_platform(request_input) - platform = Platform.new(platform_id: request_input[:id], account: request_input[:account], - platform_type: request_input[:type], - max_ttl: request_input[:max_ttl], data: request_input[:data].to_json, - modified_at: Sequel::CURRENT_TIMESTAMP, - policy_id: "#{request_input[:account]}:policy:data/platforms/#{request_input[:id]}") - platform.save -end - -def get_platform_from_db(account, platform_id) - Platform.where(account: account, platform_id: platform_id).first -end - -def list_platforms_from_db(account) - Platform.where(account: account).all -end - -def input_create_yaml(platform_id, secret_method) - return input = { - "id" => platform_id, - "default_secret_method" => secret_method - } -end - -def input_delete_yaml(platform_id) - return input = { - "id" => platform_id - } -end - -def platform_audit_success(account, platform_id, operation) - subject = { account: account, platform: platform_id } - Audit.logger.log( - Audit::Event::Platform.new( - user_id: current_user.role_id, - client_ip: request.ip, - subject: subject, - message_id: "platform", - success: true, - operation: operation - ) - ) -end - -def platform_audit_failure(account, platform_id, operation, error_message) - subject = { account: account, platform: platform_id } - Audit.logger.log( - Audit::Event::Platform.new( - user_id: current_user.role_id, - client_ip: request.ip, - subject: subject, - message_id: "platform", - success: false, - operation: operation, - error_message: error_message - ) - ) -end diff --git a/app/controllers/secrets_controller.rb b/app/controllers/secrets_controller.rb index b504174207..4e9b24a5b7 100644 --- a/app/controllers/secrets_controller.rb +++ b/app/controllers/secrets_controller.rb @@ -8,9 +8,6 @@ class SecretsController < RestController before_action :current_user - PLATFORM_PREFIX = "platform/" - EPHEMERAL_VARIABLE_PREFIX = "data/ephemerals/" - def create authorize(:update) @@ -166,7 +163,7 @@ def variable_ids end def ephemeral_secret? - resource.kind == "variable" && resource.identifier.start_with?(EPHEMERAL_VARIABLE_PREFIX) + resource.kind == "variable" && resource.identifier.start_with?(Issuer::EPHEMERAL_VARIABLE_PREFIX) end def handle_ephemeral_secret @@ -175,26 +172,26 @@ def handle_ephemeral_secret variable_data = {} request_id = request.env['action_dispatch.request_id'] - # Filter the platform related annotations and remove the prefix + # Filter the issuer related annotations and remove the prefix resource_annotations.each do |annotation| - next unless annotation.name.start_with?(PLATFORM_PREFIX) - platform_param = annotation.name.to_s[PLATFORM_PREFIX.length..-1] - variable_data[platform_param] = annotation.value + next unless annotation.name.start_with?(Issuer::EPHEMERAL_ANNOTATION_PREFIX) + issuer_param = annotation.name.to_s[Issuer::EPHEMERAL_ANNOTATION_PREFIX.length..-1] + variable_data[issuer_param] = annotation.value end - platform = Platform.where(account: account, platform_id: variable_data["id"]).first + issuer = Issuer.where(account: account, issuer_id: variable_data["id"]).first - # There shouldn't be a state where a variable belongs to a platform that doesn't exit, but we check it to be safe - raise ApplicationController::InternalServerError, "Platform assigned to #{account}:#{params[:kind]}:#{params[:identifier]} was not found" unless platform + # There shouldn't be a state where a variable belongs to an issuer that doesn't exit, but we check it to be safe + raise ApplicationController::InternalServerError, "Issuer assigned to #{account}:#{params[:kind]}:#{params[:identifier]} was not found" unless issuer - logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(variable_data["id"], platform.platform_type, variable_data["method"], request_id)) + logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(variable_data["id"], issuer.issuer_type, variable_data["method"], request_id)) - platform_data = { - max_ttl: platform.max_ttl, - data: JSON.parse(platform.data) + issuer_data = { + max_ttl: issuer.max_ttl, + data: JSON.parse(issuer.data) } ConjurEphemeralEngineClient.new(logger: logger, request_id: request_id) - .get_ephemeral_secret(platform.platform_type, variable_data["method"], @current_user.role_id, platform_data, variable_data) + .get_ephemeral_secret(issuer.issuer_type, variable_data["method"], @current_user.role_id, issuer_data, variable_data) end end diff --git a/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb b/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb similarity index 80% rename from app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb rename to app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb index 70b95f3805..606bb7d476 100644 --- a/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client.rb +++ b/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb @@ -20,13 +20,13 @@ def initialize(logger:, request_id:, http_client: nil) @request_id = request_id end - def get_ephemeral_secret(type, method, role_id, platform_data, variable_data) + def get_ephemeral_secret(type, method, role_id, issuer_data, variable_data) request_body = { type: type, method: method, role: role_id, - platform: hash_keys_to_camel_case(platform_data), - secret: hash_keys_to_camel_case(variable_data) + issuer: hash_keys_to_snake_case(issuer_data), + secret: hash_keys_to_snake_case(variable_data) } # Create the POST request @@ -60,19 +60,15 @@ def get_ephemeral_secret(type, method, role_id, platform_data, variable_data) protected - def hash_keys_to_camel_case(hash, level = 0) + def hash_keys_to_snake_case(hash, level = 0) result = {} - delimiters = %w[- _] hash.each do |key, value| - words = key.to_s.split(Regexp.union(delimiters)) - current_word = words[0].downcase - (1...words.length).each do |index| - current_word += words[index].capitalize - end + transformed_key = key.to_s.gsub("-", "_").downcase + # If the value is another hash, perform the same casting on that sub hash. # We don't want unexpected behavior so currently this is limited to one level of - result[current_word] = if value.is_a?(Hash) && level.zero? - hash_keys_to_camel_case(value, 1) + result[transformed_key] = if value.is_a?(Hash) && level.zero? + hash_keys_to_snake_case(value, 1) else value end diff --git a/app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb b/app/domain/issuers/ephemeral_engines/ephemeral_engine_client.rb similarity index 66% rename from app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb rename to app/domain/issuers/ephemeral_engines/ephemeral_engine_client.rb index 5494b4a92d..75e8b7f381 100644 --- a/app/domain/platforms/ephemeral_engines/ephemeral_engine_client.rb +++ b/app/domain/issuers/ephemeral_engines/ephemeral_engine_client.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module EphemeralEngineClient - def get_ephemeral_secret(type, method, role_id, platform_data, variable_data) + def get_ephemeral_secret(type, method, role_id, issuer_data, variable_data) raise NotImplementedError, "This method is not implemented because it's an interface" end end diff --git a/app/domain/issuers/issuer_types/aws_issuer_type.rb b/app/domain/issuers/issuer_types/aws_issuer_type.rb new file mode 100644 index 0000000000..3b2465c208 --- /dev/null +++ b/app/domain/issuers/issuer_types/aws_issuer_type.rb @@ -0,0 +1,43 @@ +require_relative './issuer_base_type' + +class AwsIssuerType < IssuerBaseType + REQUIRED_DATA_PARAM_MISSING = "'%s' is a required parameter in data and must be specified".freeze + INVALID_INPUT_PARAM = "invalid parameter received in data. Only access_key_id and secret_access_key are allowed".freeze + NUM_OF_EXPECTED_DATA_PARAMS = 2 + + def validate(params) + super + validate_data(params[:data]) + end +end + +private + +def validate_data(data) + unless data.is_a?(ActionController::Parameters) + raise ApplicationController::BadRequest, "'data' is not a valid JSON object; ensure that 'data' is properly formatted as a JSON object." + end + + data_fields = { + access_key_id: "access_key_id", + secret_access_key: "secret_access_key" + } + + data_fields.each do |field_symbol, field_string| + if data[field_symbol].nil? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, field_string) + end + + unless data[field_symbol].is_a?(String) + raise ApplicationController::BadRequest, format(IssuerBaseType::WRONG_PARAM_TYPE, field_string, "string") + end + + if data[field_symbol].empty? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, field_string) + end + end + + if data.keys.count != AwsIssuerType::NUM_OF_EXPECTED_DATA_PARAMS + raise ApplicationController::BadRequest, AwsIssuerType::INVALID_INPUT_PARAM + end +end \ No newline at end of file diff --git a/app/domain/issuers/issuer_types/issuer_base_type.rb b/app/domain/issuers/issuer_types/issuer_base_type.rb new file mode 100644 index 0000000000..685f83e1ec --- /dev/null +++ b/app/domain/issuers/issuer_types/issuer_base_type.rb @@ -0,0 +1,71 @@ +class IssuerBaseType + REQUIRED_PARAM_MISSING = "%s is a required parameter and must be specified".freeze + WRONG_PARAM_TYPE = "the '%s' parameter must be a %s".freeze + INVALID_INPUT_PARAM = "invalid parameter received in the request body. Only id, type, max_ttl and data are allowed".freeze + + ID_FIELD_ALLOWED_CHARACTERS = /\A[a-zA-Z0-9+\-_]+\z/ + ID_FIELD_MAX_ALLOWED_LENGTH = 60 + NUM_OF_EXPECTED_PARAMS = 4 + + def validate(params) + validate_id(params[:id]) + validate_max_ttl(params[:max_ttl]) + validate_type(params[:type]) + validate_no_added_parameters(params) + end +end + +private + +def validate_id(id) + if id.nil? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "id") + end + + unless id.is_a?(String) + raise ApplicationController::BadRequest, format(IssuerBaseType::WRONG_PARAM_TYPE, "id", "string") + end + + if id.empty? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "id") + end + + unless id.match?(IssuerBaseType::ID_FIELD_ALLOWED_CHARACTERS) + raise ApplicationController::BadRequest, "invalid 'id' parameter. Only the following characters are supported: A-Z, a-z, 0-9, +, -, and _" + end + + if id.length > IssuerBaseType::ID_FIELD_MAX_ALLOWED_LENGTH + raise ApplicationController::BadRequest, "'id' parameter length exceeded. Limit the length to #{IssuerBaseType::ID_FIELD_MAX_ALLOWED_LENGTH} characters" + end +end + +def validate_max_ttl(max_ttl) + if max_ttl.nil? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "max_ttl") + end + + unless max_ttl.is_a?(Integer) && max_ttl.positive? + raise ApplicationController::BadRequest, format(IssuerBaseType::WRONG_PARAM_TYPE, "max_ttl", "positive integer") + end + +end + +def validate_type(type) + if type.nil? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "type") + end + + unless type.is_a?(String) + raise ApplicationController::BadRequest, format(IssuerBaseType::WRONG_PARAM_TYPE, "type", "string") + end + + if type.empty? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "type") + end +end + +def validate_no_added_parameters(params) + if params.keys.count != IssuerBaseType::NUM_OF_EXPECTED_PARAMS + raise ApplicationController::BadRequest, IssuerBaseType::INVALID_INPUT_PARAM + end +end diff --git a/app/domain/issuers/issuer_types/issuer_type_factory.rb b/app/domain/issuers/issuer_types/issuer_type_factory.rb new file mode 100644 index 0000000000..d3db2856f4 --- /dev/null +++ b/app/domain/issuers/issuer_types/issuer_type_factory.rb @@ -0,0 +1,11 @@ +require_relative './aws_issuer_type' + +class IssuerTypeFactory + def create_issuer_type(type) + if !type.nil? && type.casecmp("aws").zero? + AwsIssuerType.new + else + raise ApplicationController::BadRequest, "issuer type is unsupported" + end + end +end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 4b6c0c965e..0662be46c4 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -28,6 +28,11 @@ module Conjur msg: "Slosilo key is rotated successfully", code: "CONJ00156I" ) + + GeneralError = ::Util::TrackableErrorClass.new( + msg: "Unexpected error occurred: {0}", + code: "CONJ00163E" + ) end module Endpoints @@ -782,15 +787,15 @@ module AuthnJwt end end - module Platforms + module Issuers - PlatformPolicyNotFound = ::Util::TrackableErrorClass.new( - msg: "The policy of platform {0} was not found", + IssuerPolicyNotFound = ::Util::TrackableErrorClass.new( + msg: "The policy of issuer {0} was not found", code: "CONJ00158E" ) - PlatformEndpointForbidden = ::Util::TrackableErrorClass.new( - msg: "Action {0} is not allowed on the platforms endpoint", + IssuerEndpointForbidden = ::Util::TrackableErrorClass.new( + msg: "Action {0} is not allowed on the issuers endpoint", code: "CONJ00159E" ) @@ -799,7 +804,7 @@ module Platforms module Secrets EphemeralSecretRequest = ::Util::TrackableLogMessageClass.new( - msg: "Received an ephemeral secret request. Platform ID [{0}], platform type [{1}], ephemeral method [{2}], Request ID [{3}]", + msg: "Received an ephemeral secret request. Issuer ID [{0}], issuer type [{1}], ephemeral method [{2}], Request ID [{3}]", code: "CONJ00160I" ) diff --git a/app/domain/platforms/platform_types/aws_platform_type.rb b/app/domain/platforms/platform_types/aws_platform_type.rb deleted file mode 100644 index c5f836cb49..0000000000 --- a/app/domain/platforms/platform_types/aws_platform_type.rb +++ /dev/null @@ -1,41 +0,0 @@ -require_relative './platform_base_type' - -class AwsPlatformType < PlatformBaseType - REQUIRED_DATA_PARAM_MISSING = "%s is a required parameter in data and must be specified".freeze - - def validate(params) - super - validate_data(params[:data]) - end - - def default_secret_method - "iam_session" - end -end - -private - -def validate_data(data) - unless data.is_a?(ActionController::Parameters) - raise ApplicationController::BadRequest, "data must be a valid JSON object" - end - - data_fields = { - access_key_id: "access_key_id", - access_key_secret: "access_key_secret" - } - - data_fields.each do |field_symbol, field_string| - if data[field_symbol].nil? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, field_string) - end - - unless data[field_symbol].is_a?(String) - raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, field_string, "string") - end - - if data[field_symbol].empty? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, field_string) - end - end -end \ No newline at end of file diff --git a/app/domain/platforms/platform_types/platform_base_type.rb b/app/domain/platforms/platform_types/platform_base_type.rb deleted file mode 100644 index fc0266bc72..0000000000 --- a/app/domain/platforms/platform_types/platform_base_type.rb +++ /dev/null @@ -1,66 +0,0 @@ -class PlatformBaseType - REQUIRED_PARAM_MISSING = "%s is a required parameter and must be specified".freeze - WRONG_PARAM_TYPE = "%s param must be a %s".freeze - - ID_FIELD_ALLOWED_CHARACTERS = /\A[a-zA-Z0-9+\-_]+\z/ - ID_FIELD_MAX_ALLOWED_LENGTH = 60 - - def validate(params) - validate_id(params[:id]) - validate_max_ttl(params[:max_ttl]) - validate_type(params[:type]) - end - - def default_secret_method - raise NotImplementedError, "This method is not implemented because it's a base class" - end -end - -private - -def validate_id(id) - if id.nil? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "id") - end - - unless id.is_a?(String) - raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, "id", "string") - end - - if id.empty? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "id") - end - - unless id.match?(PlatformBaseType::ID_FIELD_ALLOWED_CHARACTERS) - raise ApplicationController::BadRequest, "id param only supports alpha numeric characters and +-_" - end - - if id.length > PlatformBaseType::ID_FIELD_MAX_ALLOWED_LENGTH - raise ApplicationController::BadRequest, "id param must be up to #{PlatformBaseType::ID_FIELD_MAX_ALLOWED_LENGTH} characters" - end -end - -def validate_max_ttl(max_ttl) - if max_ttl.nil? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "max_ttl") - end - - unless max_ttl.is_a?(Integer) && max_ttl.positive? - raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, "max_ttl", "positive integer") - end - -end - -def validate_type(type) - if type.nil? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "type") - end - - unless type.is_a?(String) - raise ApplicationController::BadRequest, format(PlatformBaseType::WRONG_PARAM_TYPE, "type", "string") - end - - if type.empty? - raise ApplicationController::BadRequest, format(PlatformBaseType::REQUIRED_PARAM_MISSING, "type") - end -end diff --git a/app/domain/platforms/platform_types/platform_type_factory.rb b/app/domain/platforms/platform_types/platform_type_factory.rb deleted file mode 100644 index e560a40986..0000000000 --- a/app/domain/platforms/platform_types/platform_type_factory.rb +++ /dev/null @@ -1,11 +0,0 @@ -require_relative './aws_platform_type' - -class PlatformTypeFactory - def create_platform_type(type) - if !type.nil? && type.casecmp("aws").zero? - AwsPlatformType.new - else - raise ApplicationController::BadRequest, "platform type must be aws" - end - end -end diff --git a/app/domain/policy-templates/issuers/create_issuer.rb b/app/domain/policy-templates/issuers/create_issuer.rb new file mode 100644 index 0000000000..8dfe2ee9c0 --- /dev/null +++ b/app/domain/policy-templates/issuers/create_issuer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateIssuer < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !policy + id: <%= id %> + body: + - !permit + role: !group delegation/consumers + privileges: [ use ] + resource: !policy + + - !policy + id: delegation + body: + - !group consumers + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/platforms/delete_platform.rb b/app/domain/policy-templates/issuers/delete_issuer.rb similarity index 55% rename from app/domain/policy-templates/platforms/delete_platform.rb rename to app/domain/policy-templates/issuers/delete_issuer.rb index f6205c77d4..3212a88320 100644 --- a/app/domain/policy-templates/platforms/delete_platform.rb +++ b/app/domain/policy-templates/issuers/delete_issuer.rb @@ -2,21 +2,12 @@ require_relative '../base_template' module PolicyTemplates - class DeletePlatform < PolicyTemplates::BaseTemplate + class DeleteIssuer < PolicyTemplates::BaseTemplate def template <<~TEMPLATE - - !delete - record: !variable <%= id %>/secrets/default - - - !delete - record: !policy <%= id %>/secrets - - !delete record: !group <%= id %>/delegation/consumers - - !delete - record: !group <%= id %>/delegation/secrets-creators - - !delete record: !policy <%= id %>/delegation diff --git a/app/domain/policy-templates/platforms/create_platform.rb b/app/domain/policy-templates/platforms/create_platform.rb deleted file mode 100644 index 50f8e56cdb..0000000000 --- a/app/domain/policy-templates/platforms/create_platform.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true -require_relative '../base_template' - -module PolicyTemplates - class CreatePlatform < PolicyTemplates::BaseTemplate - def template - <<~TEMPLATE - - !policy - id: <%= id %> - body: - - !permit - role: !group delegation/secrets-creators - privileges: [ use ] - resource: !policy - - - !permit - role: !group delegation/secrets-creators - privileges: [ read, execute ] - resource: !variable secrets/default - - - !permit - role: !group delegation/secrets-creators - privileges: [ update ] - resource: !policy secrets - - - !permit - role: !group delegation/consumers - privileges: [ read, execute ] - resource: !variable secrets/default - - - !policy - id: delegation - body: - - !group secrets-creators - - !group consumers - - - !policy - id: secrets - body: - - !variable - id: default - annotations: - platform/id: <%= id %> - platform/method: <%= default_secret_method %> - TEMPLATE - end - end -end \ No newline at end of file diff --git a/app/models/audit/event/platform.rb b/app/models/audit/event/issuer.rb similarity index 81% rename from app/models/audit/event/platform.rb rename to app/models/audit/event/issuer.rb index a0112d81bb..012550192c 100644 --- a/app/models/audit/event/platform.rb +++ b/app/models/audit/event/issuer.rb @@ -2,7 +2,7 @@ module Audit module Event # NOTE: Breaking this class up further would harm clarity. # :reek:TooManyInstanceVariables and :reek:TooManyParameters - class Platform + class Issuer attr_reader :message_id @@ -52,14 +52,15 @@ def to_s def message if @operation == "list" attempted_action.message( - success_msg: "#{@user_id} listed platforms #{@subject[:account]}:platform:#{@subject[:platform]}", - failure_msg: "#{@user_id} tried to list platforms #{@subject[:account]}:platform:#{@subject[:platform]}", + success_msg: "#{@user_id} listed issuers #{@subject[:account]}:issuer:#{@subject[:issuer]}", + failure_msg: "#{@user_id} tried to list issuers #{@subject[:account]}:issuer:#{@subject[:issuer]}", error_msg: @error_message ) else + past_tense_verb = "#{@operation.to_s.chomp('e')}ed" attempted_action.message( - success_msg: "#{@user_id} performed #{@operation} on platform #{@subject[:account]}:platform:#{@subject[:platform]}", - failure_msg: "#{@user_id} tried to #{@operation} platform #{@subject[:account]}:platform:#{@subject[:platform]}", + success_msg: "#{@user_id} #{past_tense_verb} #{@subject[:account]}:issuer:#{@subject[:issuer]}", + failure_msg: "#{@user_id} tried to #{@operation} #{@subject[:account]}:issuer:#{@subject[:issuer]}", error_msg: @error_message ) end diff --git a/app/models/audit/event/issuer_variable.rb b/app/models/audit/event/issuer_variable.rb new file mode 100644 index 0000000000..8280300b44 --- /dev/null +++ b/app/models/audit/event/issuer_variable.rb @@ -0,0 +1,95 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class IssuerVariable + + def initialize( + user_id:, + client_ip:, + subject:, + message_id:, + success:, + operation:, + error_message: nil + ) + @user_id = user_id + @client_ip = client_ip + @subject = subject + @message_id = message_id + @success = success + @operation = operation + @error_message = error_message + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + # NOTE: original, incorrect logic, just in case we need it: + # + # @success ? Syslog::LOG_NOTICE : Syslog::LOG_WARNING + # + # The incorrect part was that a success was logged as LOG_NOTICE rather + # than LOG_INFO. This comment added June 10, 2020. Future developers + # may safely remove it if more than a couple months have elapsed. + attempted_action.severity + end + + def to_s + message + end + + def message + attempted_action.message( + success_msg: "#{@subject[:resource_id]} removed as a result of the removal of #{@subject[:account]}:issuer:#{@subject[:issuer]}", + failure_msg: "failed to remove #{@subject[:resource_id]}, following the removal of #{@subject[:account]}:issuer:#{@subject[:issuer]}", + error_msg: @error_message + ) + end + + def message_id + @message_id + end + + def structured_data + { + SDID::AUTH => { user: @user_id }, + SDID::SUBJECT => @subject, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/b753929806d0e42cdfde3f1a8dcdbf678f937e44/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + # action_sd means "action structured data" + def action_sd + attempted_action.action_sd + end + + private + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: @success, + operation: @operation + ) + end + + end + end +end diff --git a/app/models/audit/subject.rb b/app/models/audit/subject.rb index ca3a23328e..c09fc185ac 100644 --- a/app/models/audit/subject.rb +++ b/app/models/audit/subject.rb @@ -24,10 +24,10 @@ class Resource < Subject to_s { format "resource %s", resource_id } end - class Platform < Subject - field :platform_id - to_h {{ resource: platform_id }} - to_s { format "platform %s", platform_id } + class Issuer < Subject + field :issuer_id + to_h {{ resource: issuer_id }} + to_s { format "issuer %s", issuer_id } end class Role < Subject diff --git a/app/models/issuer.rb b/app/models/issuer.rb new file mode 100644 index 0000000000..bd0578177f --- /dev/null +++ b/app/models/issuer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'json' +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +require_relative '../controllers/wrappers/templates_renderer' + +class Issuer < Sequel::Model + + EPHEMERAL_ANNOTATION_PREFIX = "ephemeral/" + EPHEMERAL_VARIABLE_PREFIX = "data/ephemerals/" + + attr_encrypted :data, aad: :issuer_id + + unrestrict_primary_key + + def as_json + { + id: self.issuer_id, + max_ttl: self.max_ttl, + type: self.issuer_type, + data: JSON.parse(self.data), + created_at: self.created_at, + modified_at: self.modified_at + } + end + + def delete_issuer_variables + # Find all the variables that belong to the account and start with the ephemrals prefix + resource_ids = related_variables_query.select_map(:resource_id) + Resource.where(resource_id: resource_ids).delete + + resource_ids + end + + def issuer_variables_exist? + !related_variables_query.empty? + end + + private + + def related_variables_query + Annotation.where(Sequel.lit("resource_id LIKE ? AND value = ? AND name = ?", + "#{self.account}:variable:#{EPHEMERAL_VARIABLE_PREFIX}%", + self.issuer_id, "#{EPHEMERAL_ANNOTATION_PREFIX}issuer")) + end +end diff --git a/app/models/platform.rb b/app/models/platform.rb deleted file mode 100644 index f674074de9..0000000000 --- a/app/models/platform.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -class Platform < Sequel::Model - - attr_encrypted :data, aad: :platform_id - - unrestrict_primary_key - - def as_json - { - id: self.platform_id, - max_ttl: self.max_ttl, - type: self.platform_type, - data: JSON.parse(self.data), - created_at: self.created_at, - modified_at: self.modified_at - } - end -end diff --git a/config/routes.rb b/config/routes.rb index d70bcdcd56..fbf291755f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,10 +88,10 @@ def matches?(request) post "/edge/data/:account" => 'edge_handler#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") get "/edge/slosilo_keys/:account" => 'edge_slosilo_keys#slosilo_keys' - post "/platforms/:account" => 'platforms#create' - delete "/platforms/:account/:identifier" => 'platforms#delete' - get "/platforms/:account/:identifier" => 'platforms#get' - get "/platforms/:account" => 'platforms#list' + post "/issuers/:account" => 'issuers#create' + delete "/issuers/:account/:identifier" => 'issuers#delete' + get "/issuers/:account/:identifier" => 'issuers#get' + get "/issuers/:account" => 'issuers#list' put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' diff --git a/db/migrate/20230820152200_rename_platform_to_issuer.rb b/db/migrate/20230820152200_rename_platform_to_issuer.rb new file mode 100644 index 0000000000..37e0923dfd --- /dev/null +++ b/db/migrate/20230820152200_rename_platform_to_issuer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + # Rename the table from :platforms to :issuers + rename_table(:platforms, :issuers) + + alter_table(:issuers) do + rename_column(:platform_id, :issuer_id) + rename_column(:platform_type, :issuer_type) + + drop_constraint(:platforms_pk) + add_primary_key [:account, :issuer_id], name: :issuers_pk + drop_foreign_key(:policy_id) + add_foreign_key(:policy_id, :resources, type: String, null: false, on_delete: :cascade) + end + end +end From 6ff06fe2608623209fbbafb5bef5d6e5c945f978 Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Sun, 27 Aug 2023 17:48:20 +0300 Subject: [PATCH 093/665] Updated the tests with the platforms renaming and the fixed functionality --- cucumber/api/features/issuers.feature | 234 +++++++ cucumber/api/features/platforms.feature | 190 ------ cucumber/api/features/secrets.feature | 14 +- .../conjur_ephemeral_engine_client_spec.rb | 94 ++- .../issuer_types/aws_issuer_type_spec.rb} | 64 +- .../issuer_types/issuer_base_type_spec.rb} | 52 +- .../issuer_types/issuer_type_factory_spec.rb | 26 + .../platform_type_factory_spec.rb | 26 - ...e_spec.rb => find_issuer_resource_spec.rb} | 4 +- spec/controllers/issuers_controller_spec.rb | 598 ++++++++++++++++++ spec/controllers/platforms_controller_spec.rb | 423 ------------- 11 files changed, 978 insertions(+), 747 deletions(-) create mode 100644 cucumber/api/features/issuers.feature delete mode 100644 cucumber/api/features/platforms.feature rename spec/app/domain/{platforms => issuers}/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb (76%) rename spec/app/domain/{platforms/platform_types/aws_platform_type_spec.rb => issuers/issuer_types/aws_issuer_type_spec.rb} (57%) rename spec/app/domain/{platforms/platform_types/platform_base_type_spec.rb => issuers/issuer_types/issuer_base_type_spec.rb} (57%) create mode 100644 spec/app/domain/issuers/issuer_types/issuer_type_factory_spec.rb delete mode 100644 spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb rename spec/controllers/concerns/{find_platform_resource_spec.rb => find_issuer_resource_spec.rb} (90%) create mode 100644 spec/controllers/issuers_controller_spec.rb delete mode 100644 spec/controllers/platforms_controller_spec.rb diff --git a/cucumber/api/features/issuers.feature b/cucumber/api/features/issuers.feature new file mode 100644 index 0000000000..464a19de9b --- /dev/null +++ b/cucumber/api/features/issuers.feature @@ -0,0 +1,234 @@ +@api +@logged-in +Feature: Issuers audits tests + + Background: + Given I am the super-user + And I successfully POST "/policies/cucumber/policy/root" with body: + """ + - !policy + id: conjur/issuers + body: [] + """ + And I set the "Content-Type" header to "application/json" + And I successfully POST "/issuers/cucumber" with body: + """ + { + "id": "aws-issuer-1", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + """ + + @smoke + Scenario: Successful audit when creating a issuer + + Given I am the super-user + And I save my place in the audit log file for remote + When I set the "Content-Type" header to "application/json" + And I successfully POST "/issuers/cucumber" with body: + """ + { + "id": "aws-new-issuer", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + """ + Then the HTTP response status code is 201 + And there is an audit record matching: + """ + <86>1 * - conjur * issuer + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" issuer="aws-new-issuer"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="add"] + cucumber:user:admin added cucumber:issuer:aws-new-issuer + """ + + @smoke + Scenario: Failure audit when creating an issuer + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I set the "Content-Type" header to "application/json" + And I POST "/issuers/cucumber" with body: + """ + { + "id": "aws-new-issuer", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + """ + Then the HTTP response status code is 403 + And there is an audit record matching: + """ + <84>1 * - conjur * issuer + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" issuer="aws-new-issuer"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="add"] + cucumber:user:alice tried to add cucumber:issuer:aws-new-issuer: Policy 'conjur/issuers' not found in account 'cucumber' + """ + + @smoke + Scenario: Successful audit when getting an issuer + + Given I am the super-user + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I successfully GET "/issuers/cucumber/aws-issuer-1" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <86>1 * - conjur * issuer + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" issuer="aws-issuer-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="fetch"] + cucumber:user:admin fetched cucumber:issuer:aws-issuer-1 + """ + + @smoke + Scenario: Failure audit when getting an issuer + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I GET "/issuers/cucumber/aws-issuer-1" + Then the HTTP response status code is 404 + And there is an audit record matching: + """ + <84>1 * - conjur * issuer + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" issuer="aws-issuer-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="fetch"] + cucumber:user:alice tried to fetch cucumber:issuer:aws-issuer-1: Issuer not found + """ + + @smoke + Scenario: Successful audit when listing issuers + + Given I am the super-user + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I successfully GET "/issuers/cucumber" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <86>1 * - conjur * issuer + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" issuer="*"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="list"] + cucumber:user:admin listed issuers cucumber:issuer:* + """ + + @smoke + Scenario: Failure audit when listing issuers + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I GET "/issuers/cucumber" + Then the HTTP response status code is 403 + And there is an audit record matching: + """ + <84>1 * - conjur * issuer + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" issuer="*"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="list"] + cucumber:user:alice tried to list issuers cucumber:issuer:*: Policy 'conjur/issuers' not found in account 'cucumber' + """ + + @smoke + Scenario: Failure audit when deleting a issuer + + Given I am a user named "alice" + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I DELETE "/issuers/cucumber/aws-issuer-1" + Then the HTTP response status code is 404 + And there is an audit record matching: + """ + <84>1 * - conjur * issuer + [auth@43868 user="cucumber:user:alice"] + [subject@43868 account="cucumber" issuer="aws-issuer-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"][action@43868 result="failure" operation="remove"] + cucumber:user:alice tried to remove cucumber:issuer:aws-issuer-1: Policy 'conjur/issuers' not found in account 'cucumber' + """ + + @smoke + Scenario: Successful audit when deleting a issuer + + Given I am the super-user + And I successfully POST "/policies/cucumber/policy/root" with body: + """ + - !policy + id: data/ephemerals + body: + - !variable + id: my-ephemeral-secret + annotations: + ephemeral/issuer: aws-issuer-1 + + - !policy + id: data + body: + - !variable + id: my-non-ephemeral-secret + """ + And I successfully POST "/policies/cucumber/policy/data/ephemerals" with body: + """ + - !policy + id: inner-policy + body: + - !variable + id: my-other-ephemeral-secret + annotations: + ephemeral/issuer: aws-issuer-1 + """ + And I save my place in the audit log file for remote + When I clear the "Content-Type" header + And I successfully DELETE "/issuers/cucumber/aws-issuer-1" + Then the HTTP response status code is 200 + And there is an audit record matching: + """ + <86>1 * - conjur * issuer + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" issuer="aws-issuer-1"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="remove"] + cucumber:user:admin removed cucumber:issuer:aws-issuer-1 + """ + And there is an audit record matching: + """ + <86>1 * - conjur * variable + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" issuer="aws-issuer-1" resource_id="cucumber:variable:data/ephemerals/my-ephemeral-secret"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="remove"] + cucumber:variable:data/ephemerals/my-ephemeral-secret removed as a result of the removal of cucumber:issuer:aws-issuer-1 + """ + And there is an audit record matching: + """ + <86>1 * - conjur * variable + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" issuer="aws-issuer-1" resource_id="cucumber:variable:data/ephemerals/inner-policy/my-other-ephemeral-secret"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="remove"] + cucumber:variable:data/ephemerals/inner-policy/my-other-ephemeral-secret removed as a result of the removal of cucumber:issuer:aws-issuer-1 + """ diff --git a/cucumber/api/features/platforms.feature b/cucumber/api/features/platforms.feature deleted file mode 100644 index 73ec690f7e..0000000000 --- a/cucumber/api/features/platforms.feature +++ /dev/null @@ -1,190 +0,0 @@ -@api -@logged-in -Feature: Platforms audits tests - - Background: - Given I am the super-user - And I successfully POST "/policies/cucumber/policy/root" with body: - """ - - !policy - id: data/platforms - body: [] - """ - And I set the "Content-Type" header to "application/json" - And I successfully POST "/platforms/cucumber" with body: - """ - { - "id": "aws-platform-1", - "max_ttl": 3000, - "type": "aws", - "data": { - "access_key_id": "my-key-id", - "access_key_secret": "my-key-secret" - } - } - """ - - @smoke - Scenario: Successful audit when creating a platform - - Given I am the super-user - And I save my place in the audit log file for remote - When I set the "Content-Type" header to "application/json" - And I successfully POST "/platforms/cucumber" with body: - """ - { - "id": "aws-new-platform", - "max_ttl": 3000, - "type": "aws", - "data": { - "access_key_id": "my-key-id", - "access_key_secret": "my-key-secret" - } - } - """ - Then the HTTP response status code is 201 - And there is an audit record matching: - """ - <86>1 * - conjur * platform - [auth@43868 user="cucumber:user:admin"] - [subject@43868 account="cucumber" platform="aws-new-platform"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="success" operation="create"] - cucumber:user:admin performed create on platform cucumber:platform:aws-new-platform - """ - - @smoke - Scenario: Failure audit when creating a platform - - Given I am a user named "alice" - And I save my place in the audit log file for remote - When I set the "Content-Type" header to "application/json" - And I POST "/platforms/cucumber" with body: - """ - { - "id": "aws-new-platform", - "max_ttl": 3000, - "type": "aws", - "data": { - "access_key_id": "my-key-id", - "access_key_secret": "my-key-secret" - } - } - """ - Then the HTTP response status code is 403 - And there is an audit record matching: - """ - <84>1 * - conjur * platform - [auth@43868 user="cucumber:user:alice"] - [subject@43868 account="cucumber" platform="aws-new-platform"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="failure" operation="create"] - cucumber:user:alice tried to create platform cucumber:platform:aws-new-platform: Policy 'data/platforms' not found in account 'cucumber' - """ - - @smoke - Scenario: Successful audit when getting a platform - - Given I am the super-user - And I save my place in the audit log file for remote - When I clear the "Content-Type" header - And I successfully GET "/platforms/cucumber/aws-platform-1" - Then the HTTP response status code is 200 - And there is an audit record matching: - """ - <86>1 * - conjur * platform - [auth@43868 user="cucumber:user:admin"] - [subject@43868 account="cucumber" platform="aws-platform-1"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="success" operation="get"] - cucumber:user:admin performed get on platform cucumber:platform:aws-platform-1 - """ - - @smoke - Scenario: Failure audit when getting a platform - - Given I am a user named "alice" - And I save my place in the audit log file for remote - When I clear the "Content-Type" header - And I GET "/platforms/cucumber/aws-platform-1" - Then the HTTP response status code is 404 - And there is an audit record matching: - """ - <84>1 * - conjur * platform - [auth@43868 user="cucumber:user:alice"] - [subject@43868 account="cucumber" platform="aws-platform-1"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="failure" operation="get"] - cucumber:user:alice tried to get platform cucumber:platform:aws-platform-1: Platform not found - """ - - @smoke - Scenario: Successful audit when listing platforms - - Given I am the super-user - And I save my place in the audit log file for remote - When I clear the "Content-Type" header - And I successfully GET "/platforms/cucumber" - Then the HTTP response status code is 200 - And there is an audit record matching: - """ - <86>1 * - conjur * platform - [auth@43868 user="cucumber:user:admin"] - [subject@43868 account="cucumber" platform="*"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="success" operation="list"] - cucumber:user:admin listed platforms cucumber:platform:* - """ - - @smoke - Scenario: Failure audit when listing platforms - - Given I am a user named "alice" - And I save my place in the audit log file for remote - When I clear the "Content-Type" header - And I GET "/platforms/cucumber" - Then the HTTP response status code is 403 - And there is an audit record matching: - """ - <84>1 * - conjur * platform - [auth@43868 user="cucumber:user:alice"] - [subject@43868 account="cucumber" platform="*"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="failure" operation="list"] - cucumber:user:alice tried to list platforms cucumber:platform:*: Policy 'data/platforms' not found in account 'cucumber' - """ - - @smoke - Scenario: Failure audit when deleting a platform - - Given I am a user named "alice" - And I save my place in the audit log file for remote - When I clear the "Content-Type" header - And I DELETE "/platforms/cucumber/aws-platform-1" - Then the HTTP response status code is 404 - And there is an audit record matching: - """ - <84>1 * - conjur * platform - [auth@43868 user="cucumber:user:alice"] - [subject@43868 account="cucumber" platform="aws-platform-1"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"][action@43868 result="failure" operation="delete"] - cucumber:user:alice tried to delete platform cucumber:platform:aws-platform-1: Policy 'data/platforms' not found in account 'cucumber' - """ - - @smoke - Scenario: Successful audit when deleting a platform - - Given I am the super-user - And I save my place in the audit log file for remote - When I clear the "Content-Type" header - And I successfully DELETE "/platforms/cucumber/aws-platform-1" - Then the HTTP response status code is 200 - And there is an audit record matching: - """ - <86>1 * - conjur * platform - [auth@43868 user="cucumber:user:admin"] - [subject@43868 account="cucumber" platform="aws-platform-1"] - [client@43868 ip="\d+\.\d+\.\d+\.\d+"] - [action@43868 result="success" operation="delete"] - cucumber:user:admin performed delete on platform cucumber:platform:aws-platform-1 - """ \ No newline at end of file diff --git a/cucumber/api/features/secrets.feature b/cucumber/api/features/secrets.feature index 6783b44513..894d63734a 100644 --- a/cucumber/api/features/secrets.feature +++ b/cucumber/api/features/secrets.feature @@ -223,7 +223,7 @@ Feature: Adding and fetching secrets Scenario: Fail on permissions first when trying to update an ephemeral secret value without permissions Given I create a new "variable" resource called "ephemeral" - And I set annotation "platform/id" to "my-platform" + And I set annotation "ephemeral/issuer" to "my-issuer" When I am a user named "alice" And I POST "/secrets/cucumber/variable/ephemeral" with body: """ @@ -239,15 +239,3 @@ Feature: Adding and fetching secrets Given I create a new "variable" resource called "data/ephemerals/ephemeral" When I GET "/secrets/cucumber/variable/data/ephemerals/ephemeral" Then the HTTP response status code is 500 - - @acceptance - Scenario: Succeed to retrieve a secret with a platform ID that's outside the expected policy - - Given I create a new "variable" resource called "data/ephemeral" - And I set annotation "platform/id" to "my-platform" - And I POST "/secrets/cucumber/variable/data/ephemeral" with body: - """ - v-1 - """ - When I GET "/secrets/cucumber/variable/data/ephemeral" - Then the HTTP response status code is 200 diff --git a/spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb b/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb similarity index 76% rename from spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb rename to spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb index 1fcdd68ce8..4f45811690 100644 --- a/spec/app/domain/platforms/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb +++ b/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb @@ -7,10 +7,10 @@ def initialize(logger:, request_id:, http_client: nil) super(logger: logger, request_id: request_id, http_client: http_client) end - def hash_keys_to_camel_case(hash, level = 0) + def hash_keys_to_snake_case(hash, level = 0) super(hash, level) end - + def tenant_id super end @@ -21,7 +21,7 @@ def initialize(body, code) @body = body @code = code end - + attr_reader :code attr_reader :body end @@ -34,17 +34,11 @@ def initialize(body, code) "id" => "b549465d-140b-475e-ada3-bc50e07d09da", "ttl" => 1000, "data" => { - "Credentials" => { - "AccessKeyId" => "aws_key_id", - "SecretAccessKey" => "aws_key_secret", - "SessionToken" => "session_token", - "Expiration" => "2023-07-03T15:28:23+00:00" - }, - "FederatedUser" => { - "FederatedUserId" => "238637036211:conjur,host,data.my-app", - "Arn" => "arn:aws:sts::238637036211:federated-user/conjur,host,data.my-app" - }, - "PackedPolicySize" => 16 + "access_key_id" => "aws_key_id", + "secret_access_key" => "secret_access_key", + "session_token" => "session_token", + "federated_user_id" => "238637036211:conjur,host,data.my-app", + "federatedUserArn" => "arn:aws:sts::238637036211:federated-user/conjur,host,data.my-app" } } end @@ -56,29 +50,29 @@ def initialize(body, code) "description" => "Error description" } end - + def mock_ephemeral_secrets_service(response_code) double('net_http_post').tap do |net_http_post| if response_code allow(net_http_post).to receive(:request) - .and_return(MockHttpResponse.new(JSON.generate(mock_secret_error), response_code)) + .and_return(MockHttpResponse.new(JSON.generate(mock_secret_error), response_code)) else allow(net_http_post).to receive(:request) - .and_return(MockHttpResponse.new(JSON.generate(mock_secret_result), 201)) + .and_return(MockHttpResponse.new(JSON.generate(mock_secret_result), 201)) end end end - + context "when all input is valid" do it "then an ephemeral secret is returned" do - platform_type = "aws" - platform_method = "iam_federation" + issuer_type = "aws" + issuer_method = "iam_federation" role_id = "conjur:host:data/my-host" - platform_data = { + issuer_data = { max_ttl: 3000, data: { access_key_id: "my_key_id", - access_key_secret: "my_secret_key" + secret_access_key: "my_secret_key" } } variable_data = { @@ -89,12 +83,12 @@ def mock_ephemeral_secrets_service(response_code) expect do MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(nil)) - .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) end .to_not raise_error result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(nil)) - .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) expect(result).to eq(mock_secret_result) end @@ -102,13 +96,13 @@ def mock_ephemeral_secrets_service(response_code) context "when there are failures from the ephemeral secrets service" do it "then the appropriate exception is raised" do - platform_type = "aws" - platform_method = "iam_federation" + issuer_type = "aws" + issuer_method = "iam_federation" role_id = "conjur:host:data/my-host" - platform_data = { + issuer_data = { data: { access_key_id: "my_key_id", - access_key_secret: "my_secret_key" + secret_access_key: "my_secret_key" } } variable_data = { @@ -119,14 +113,14 @@ def mock_ephemeral_secrets_service(response_code) expect do MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service("400")) - .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) end.to raise_error(ApplicationController::BadRequest) do |error| expect(error.message).to eq("Failed to create the ephemeral secret. Code: Error code, Message: Error message, description: Error description") end expect do MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service("500")) - .get_ephemeral_secret(platform_type, platform_method, role_id, platform_data, variable_data) + .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) end.to raise_error(ApplicationController::InternalServerError) do |error| expect(error.message).to eq("Failed to create the ephemeral secret. Code: Error code, Message: Error message, description: Error description") end @@ -141,13 +135,13 @@ def mock_ephemeral_secrets_service(response_code) "KeyThree": "ValueThree" } end - + it "then they are trimmed and turned to upper case" do result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) - .hash_keys_to_camel_case(hash) + .hash_keys_to_snake_case(hash) expected_result = { - "keyOne" => "value-one", - "keyTwo" => "value_two", + "key_one" => "value-one", + "key_two" => "value_two", "keythree" => "ValueThree" } @@ -169,13 +163,13 @@ def mock_ephemeral_secrets_service(response_code) it "then the sub hash is transformed as well" do result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) - .hash_keys_to_camel_case(hash) + .hash_keys_to_snake_case(hash) expected_result = { - "keyOne" => "value-one", - "keyTwo" => "value_two", + "key_one" => "value-one", + "key_two" => "value_two", "data" => { - "subKeyOne" => "sub-key-value", - "subKeyTwo" => "sub_key_value" + "sub_key_one" => "sub-key-value", + "sub_key_two" => "sub_key_value" } } @@ -194,10 +188,10 @@ def mock_ephemeral_secrets_service(response_code) it "then they are transformed ok" do result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) - .hash_keys_to_camel_case(hash) + .hash_keys_to_snake_case(hash) expected_result = { - "keyOne" => "value-one", - "keyTwo" => "value_two", + "key_one" => "value-one", + "key_two" => "value_two", "keythree" => "valueThree" } @@ -216,10 +210,10 @@ def mock_ephemeral_secrets_service(response_code) it "then they are normalized into camel case" do result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) - .hash_keys_to_camel_case(hash) + .hash_keys_to_snake_case(hash) expected_result = { - "keyOne" => "value-one", - "keyTwo" => "value_two", + "key_one" => "value-one", + "key_two" => "value_two", "keythree" => "valueThree" } @@ -238,10 +232,10 @@ def mock_ephemeral_secrets_service(response_code) it "then they are normalized into camel case" do result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) - .hash_keys_to_camel_case(hash) + .hash_keys_to_snake_case(hash) expected_result = { - "keyOne#" => "value-one", - "key%two" => "value_two", + "key_one#" => "value-one", + "key_%two" => "value_two", "keythre*e" => "valueThree" } @@ -250,11 +244,11 @@ def mock_ephemeral_secrets_service(response_code) end context "when hash is empty" do - let(:hash) {{}} + let(:hash) { {} } it "then the result is empty as well" do result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)) - .hash_keys_to_camel_case(hash) + .hash_keys_to_snake_case(hash) expected_result = {} expect(result).to eq(expected_result) diff --git a/spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb b/spec/app/domain/issuers/issuer_types/aws_issuer_type_spec.rb similarity index 57% rename from spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb rename to spec/app/domain/issuers/issuer_types/aws_issuer_type_spec.rb index d09f8c6e64..c651b6bc36 100644 --- a/spec/app/domain/platforms/platform_types/aws_platform_type_spec.rb +++ b/spec/app/domain/issuers/issuer_types/aws_issuer_type_spec.rb @@ -1,100 +1,114 @@ # frozen_string_literal: true require 'spec_helper' -describe "AwsPlatformType input validation" do +describe "AwsIssuerType input validation" do context "when all input is valid" do it "then the input validation succeeds" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { access_key_id: "a", - access_key_secret: "a" + secret_access_key: "a" }) - - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } .to_not raise_error end end context "when key id is not given in the data field" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { - access_key_secret: "a" + secret_access_key: "a" }) - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end - context "when key secret is not given in the data field" do + context "when secret access key is not given in the data field" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { access_key_id: "a" }) - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end context "when key id is not a string" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { access_key_id: 1, - access_key_secret: "a" + secret_access_key: "a" }) - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end context "when key id is an empty string" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { access_key_id: "", - access_key_secret: "a" + secret_access_key: "a" }) - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end - context "when key secret is not a string" do + context "when secret access key is not a string" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { access_key_id: "a", - access_key_secret: 1 + secret_access_key: 1 }) - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end - context "when key secret is an empty string" do + context "when secret access key is an empty string" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, type: "aws", data: { access_key_id: "a", - access_key_secret: "" + secret_access_key: "" }) - expect { AwsPlatformType.new.validate(params) } + expect { AwsIssuerType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end + + context "when invalid parameter is added to the data" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-issuer-1", + max_ttl: 2000, + type: "aws", + data: { + access_key_id: "a", + secret_access_key: "", + invalid_param: "a" + }) + expect { AwsIssuerType.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end diff --git a/spec/app/domain/platforms/platform_types/platform_base_type_spec.rb b/spec/app/domain/issuers/issuer_types/issuer_base_type_spec.rb similarity index 57% rename from spec/app/domain/platforms/platform_types/platform_base_type_spec.rb rename to spec/app/domain/issuers/issuer_types/issuer_base_type_spec.rb index 7f6bd30a95..60097a25a1 100644 --- a/spec/app/domain/platforms/platform_types/platform_base_type_spec.rb +++ b/spec/app/domain/issuers/issuer_types/issuer_base_type_spec.rb @@ -1,23 +1,20 @@ # frozen_string_literal: true require 'spec_helper' -class BaseTypeTest < PlatformBaseType +class BaseTypeTest < IssuerBaseType def validate(params) super end - - def default_secret_method - super - end end -describe "PlatformBaseType input validation" do +describe "IssuerBaseType input validation" do context "when all base input is valid" do it "then the input validation succeeds" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 2000, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to_not raise_error end @@ -25,9 +22,10 @@ def default_secret_method context "when max_ttl is a negative number" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: -2000, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end @@ -35,9 +33,10 @@ def default_secret_method context "when max_ttl is 0" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 0, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end @@ -45,9 +44,10 @@ def default_secret_method context "when max_ttl is a floating number" do it "then the input validation fails" do - params = ActionController::Parameters.new(id: "aws-platform-1", + params = ActionController::Parameters.new(id: "aws-issuer-1", max_ttl: 4.5, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end @@ -57,7 +57,8 @@ def default_secret_method it "then the input validation fails" do params = ActionController::Parameters.new(id: 1, max_ttl: 2000, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end @@ -67,7 +68,8 @@ def default_secret_method it "then the input validation fails" do params = ActionController::Parameters.new(id: "a" * 61, max_ttl: 2000, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end @@ -77,7 +79,8 @@ def default_secret_method it "then the input validation fails" do params = ActionController::Parameters.new(id: "a^", max_ttl: 2000, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end @@ -87,9 +90,22 @@ def default_secret_method it "then the input validation fails" do params = ActionController::Parameters.new(id: "a hf", max_ttl: 2000, - type: "aws") + type: "aws", + data: {}) expect { BaseTypeTest.new.validate(params) } .to raise_error(ApplicationController::BadRequest) end end + + context "when invalid parameter is added to the body main section" do + it "then the input validation fails" do + params = ActionController::Parameters.new(id: "aws-issuer-1", + max_ttl: 2000, + type: "aws", + invalid_param: "a", + data: {}) + expect { AwsIssuerType.new.validate(params) } + .to raise_error(ApplicationController::BadRequest) + end + end end diff --git a/spec/app/domain/issuers/issuer_types/issuer_type_factory_spec.rb b/spec/app/domain/issuers/issuer_types/issuer_type_factory_spec.rb new file mode 100644 index 0000000000..718c89c331 --- /dev/null +++ b/spec/app/domain/issuers/issuer_types/issuer_type_factory_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "IssuerTypeFactory input validation" do + + context "when issuer type is supported" do + it "then relevant issuer type is being created" do + expect { IssuerTypeFactory.new.create_issuer_type("aws") } + .to_not raise_error + end + end + + context "when issuer type is supported but with upper case" do + it "then relevant issuer type is being created" do + expect { IssuerTypeFactory.new.create_issuer_type("AWS") } + .to_not raise_error + end + end + + context "when issuer type is not supported" do + it "then the factory returns an error" do + expect { IssuerTypeFactory.new.create_issuer_type("abc") } + .to raise_error(ApplicationController::BadRequest) + end + end +end diff --git a/spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb b/spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb deleted file mode 100644 index d4b2a167ea..0000000000 --- a/spec/app/domain/platforms/platform_types/platform_type_factory_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -describe "PlatformTypeFactory input validation" do - - context "when platform type is supported" do - it "then relevant platform type is being created" do - expect { PlatformTypeFactory.new.create_platform_type("aws") } - .to_not raise_error - end - end - - context "when platform type is supported but with upper case" do - it "then relevant platform type is being created" do - expect { PlatformTypeFactory.new.create_platform_type("AWS") } - .to_not raise_error - end - end - - context "when platform type is not supported" do - it "then the factory returns an error" do - expect { PlatformTypeFactory.new.create_platform_type("abc") } - .to raise_error(ApplicationController::BadRequest) - end - end -end diff --git a/spec/controllers/concerns/find_platform_resource_spec.rb b/spec/controllers/concerns/find_issuer_resource_spec.rb similarity index 90% rename from spec/controllers/concerns/find_platform_resource_spec.rb rename to spec/controllers/concerns/find_issuer_resource_spec.rb index 465d5862d3..52c392fe1b 100644 --- a/spec/controllers/concerns/find_platform_resource_spec.rb +++ b/spec/controllers/concerns/find_issuer_resource_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -describe FindPlatformResource do +describe FindIssuerResource do context "when resource cannot be found" do let(:resource) { nil } describe '#resource' do @@ -21,7 +21,7 @@ # Test controller class class Controller - include FindPlatformResource + include FindIssuerResource end subject(:controller) { Controller.new } diff --git a/spec/controllers/issuers_controller_spec.rb b/spec/controllers/issuers_controller_spec.rb new file mode 100644 index 0000000000..62f48352fa --- /dev/null +++ b/spec/controllers/issuers_controller_spec.rb @@ -0,0 +1,598 @@ +# frozen_string_literal: true + +require 'spec_helper' +DatabaseCleaner.strategy = :truncation + +describe IssuersController, type: :request do + let(:url_resource) { "/resources/rspec" } + before do + init_slosilo_keys("rspec") + # Load the base conjur/issuers policies into Conjur + + put( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => data_issuers_policy + ) + ) + assert_response :success + + end + + let(:data_issuers_policy) do + <<~POLICY + - !policy + id: conjur/issuers + body: [] + POLICY + end + + let(:admin_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + let(:current_user) { Role.find_or_create(role_id: current_user_id) } + let(:current_user_id) { 'rspec:user:admin' } + let(:alice_user) { Role.find_or_create(role_id: alice_user_id) } + let(:alice_user_id) { 'rspec:user:alice' } + + describe "#create" do + context "when a user sends body with id only" do + let(:payload_create_issuers_only_id) do + <<~BODY + { "id": "new-issuer" } + BODY + end + it 'returns bad request' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuers_only_id, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + end + end + + context "when user sends body with id, max_ttl, type and data" do + let(:payload_create_issuers_complete_input) do + <<~BODY + { + "id": "aws-issuer-1", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + BODY + end + it 'it returns created' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuers_complete_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + parsed_body = JSON.parse(response.body) + expect(parsed_body["id"]).to eq("aws-issuer-1") + expect(parsed_body["max_ttl"]).to eq(3000) + expect(parsed_body["type"]).to eq("aws") + expect(parsed_body["data"]["access_key_id"]).to eq("my-key-id") + expect(parsed_body["data"]["secret_access_key"]).to eq("my-key-secret") + expect(response.body).to include("\"created_at\"") + expect(response.body).to include("\"modified_at\"") + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/aws-issuer-1")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/aws-issuer-1/delegation")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:group:conjur/issuers/aws-issuer-1/delegation/consumers")).not_to eq(nil) + end + end + + context "when user creates an issuer with unsupported symbols in its name" do + let(:payload_create_issuers_symbols_input) do + <<~BODY + { + "id": "aws-issuer-!@\#$%^*()[]", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + BODY + end + it 'it returns created' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuers_symbols_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + parsed_body = JSON.parse(response.body) + expect(parsed_body["error"]["code"]).to eq("bad_request") + expect(parsed_body["error"]["message"]).to eq("invalid 'id' parameter. Only the following characters are supported: A-Z, a-z, 0-9, +, -, and _") + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/aws-issuer-!@#$%^*()[]")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/aws-issuer-!@#$%^*()[]/delegation")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:conjur/issuers/aws-issuer-!@#$%^*()[]/delegation/consumers")).to eq(nil) + end + end + + context "when user tries to create an issuer but there are existing variables related to that id" do + let(:payload_create_issuer_input) do + <<~BODY + { + "id": "my-new-aws-issuer", + "max_ttl": 2000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + BODY + end + let(:payload_create_ephemeral_variables) do + <<~POLICY + - !policy + id: data/ephemerals + body: + - !variable + id: related-ephemeral-variable + annotations: + ephemeral/issuer: my-new-aws-issuer + POLICY + end + it 'it returns conflict' do + post( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_ephemeral_variables + ) + ) + assert_response :success + expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/related-ephemeral-variable")).to_not eq(nil) + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :internal_server_error + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer/delegation")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:conjur/issuers/my-new-aws-issuer/delegation/consumers")).to eq(nil) + end + end + + context "when user sends an empty body" do + let(:payload_empty) do + <<~BODY + { + } + BODY + end + it 'it returns bad_request' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_empty, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + end + end + + context "when user sends an empty id" do + let(:payload_blank_id) do + <<~BODY + { + "id": "" + } + BODY + end + it "it returns bad_request" do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_blank_id, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + expect(response.body).to eq("{\"error\":{\"code\":\"bad_request\",\"message\":\"issuer type is unsupported\"}}") + + end + end + + context "when user specifies an unsupported parameter in the body" do + let(:payload_create_issuers_symbols_input) do + <<~BODY + { + "id": "aws-issuer-1", + "max_ttl": 3000, + "unsupported_parameter": "aaa", + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + BODY + end + it 'it returns bad_request' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuers_symbols_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + expect(response.body).to eq("{\"error\":{\"code\":\"bad_request\",\"message\":\"invalid parameter received in the request body. Only id, type, max_ttl and data are allowed\"}}") + end + end + + context "when user specifies an unsupported parameter in the body data" do + let(:payload_create_issuers_symbols_input) do + <<~BODY + { + "id": "aws-issuer-1", + "max_ttl": 3000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret", + "unsupported_parameter": "aaa" + } + } + BODY + end + it 'it returns bad_request' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuers_symbols_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + expect(response.body).to eq("{\"error\":{\"code\":\"bad_request\",\"message\":\"invalid parameter received in data. Only access_key_id and secret_access_key are allowed\"}}") + end + end + + context "when user sends a valid creation request but without permissions" do + let(:payload_create_issuers_valid_input) do + <<~BODY + { + "id": "valid-issuer", + "max_ttl": 1000, + "type": "aws", + "data": { + "access_key_id": "my-key-id", + "secret_access_key": "my-key-secret" + } + } + BODY + end + it 'returns forbidden' do + post("/issuers/rspec", + env: token_auth_header(role: alice_user).merge( + 'RAW_POST_DATA' => payload_create_issuers_valid_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :forbidden + expect(response.body).to eq("") + end + end + end + + describe "#delete" do + context "when a user deletes a issuer that does not exist" do + it 'it returns not found' do + delete("/issuers/rspec/non-existing-issuer", + env: token_auth_header(role: admin_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Issuer not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"non-existing-issuer\"}}}") + end + end + + context "when a user deletes an existing issuer" do + let(:payload_create_issuer) do + <<~BODY + { + "id": "my-new-aws-issuer", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "aaa", + "secret_access_key": "a" + } + } + BODY + end + it 'it is deleted successfully' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + delete("/issuers/rspec/my-new-aws-issuer", env: token_auth_header(role: admin_user)) + assert_response :success + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer/delegation")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:conjur/issuers/my-new-aws-issuer/delegation/consumers")).to eq(nil) + end + + context "when a user deletes a non existing issuer without permissions" do + it 'it returns not found' do + delete("/issuers/rspec/non-existing-issuer", env: token_auth_header(role: alice_user)) + assert_response :not_found + end + end + end + + context "when a user deletes an issuer that has variables assigned to it" do + let(:payload_create_issuer) do + <<~BODY + { + "id": "my-new-aws-issuer", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "aaa", + "secret_access_key": "a" + } + } + BODY + end + let(:payload_create_ephemeral_variables) do + <<~POLICY + - !policy + id: data/ephemerals + body: + - !variable + id: related-ephemeral-variable + annotations: + ephemeral/issuer: my-new-aws-issuer + - !variable + id: unrelated-ephemeral-variable + annotations: + ephemeral/issuer: my-other-issuer + POLICY + end + let(:payload_create_non_ephemeral_variable) do + <<~POLICY + - !policy + id: data + body: + - !variable + id: non-ephemeral-variable + annotations: + ephemeral/issuer: my-new-aws-issuer + POLICY + end + it 'deletes both issuer and related ephemeral variables successfully' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + post( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_ephemeral_variables + ) + ) + assert_response :success + expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/related-ephemeral-variable")).to_not eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/unrelated-ephemeral-variable")).to_not eq(nil) + post( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_non_ephemeral_variable + ) + ) + assert_response :success + + delete("/issuers/rspec/my-new-aws-issuer", env: token_auth_header(role: admin_user)) + assert_response :success + # Issuer related resources are expected to be deleted, along with the ephemeral variables related to it + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer")).to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer/delegation")).to eq(nil) + expect(Resource.find(resource_id: "rspec:group:conjur/issuers/my-new-aws-issuer/delegation/consumers")).to eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/related-ephemeral-variable")).to eq(nil) + + # Non related ephemeral variables and non ephemeral variables are not deleted + expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/unrelated-ephemeral-variable")).to_not eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/non-ephemeral-variable")).to_not eq(nil) + end + + context "when a user deletes a non existing issuer without permissions" do + it 'it returns not found' do + delete("/issuers/rspec/non-existing-issuer", env: token_auth_header(role: alice_user)) + assert_response :not_found + end + end + end + + context "when a user tries to delete a issuer without the correct permissions" do + let(:payload_create_issuer) do + <<~BODY + { + "id": "issuer-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "secret_access_key": "a" + } + } + BODY + end + it 'it returns not found' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + delete("/issuers/rspec/issuer-1", + env: token_auth_header(role: alice_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Issuer not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"issuer-1\"}}}") + end + end + end + + describe "#get" do + context "when a user gets a issuer that exists" do + let(:payload_create_issuer) do + <<~BODY + { + "id": "issuer-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "secret_access_key": "a" + } + } + BODY + end + it 'the issuer is returned' do + + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + get("/issuers/rspec/issuer-1", + env: token_auth_header(role: admin_user)) + assert_response :success + parsed_body = JSON.parse(response.body) + expect(parsed_body["id"]).to eq("issuer-1") + expect(parsed_body["max_ttl"]).to eq(200) + expect(parsed_body["type"]).to eq("aws") + expect(parsed_body["data"]["access_key_id"]).to eq("a") + expect(parsed_body["data"]["secret_access_key"]).to eq("a") + expect(response.body).to include("\"created_at\"") + expect(response.body).to include("\"modified_at\"") + end + end + + context "when a user gets a issuer that does not exist" do + it 'the response is not found' do + get("/issuers/rspec/non-existing-issuer", + env: token_auth_header(role: admin_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Issuer not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"non-existing-issuer\"}}}") + end + end + + context "when a user that does not have permissions on issuers, gets a issuer" do + let(:payload_create_issuer) do + <<~BODY + { + "id": "issuer-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "secret_access_key": "a" + } + } + BODY + end + it 'the response is not found' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + get("/issuers/rspec/issuer-1", + env: token_auth_header(role: alice_user)) + assert_response :not_found + expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Issuer not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"issuer-1\"}}}") + end + end + end + + describe "#list" do + context "when a user lists the issuers" do + let(:payload_create_issuer_1) do + <<~BODY + { + "id": "issuer-1", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "a", + "secret_access_key": "a" + } + } + BODY + end + let(:payload_create_issuer_2) do + <<~BODY + { + "id": "issuer-2", + "max_ttl": 300, + "type": "aws", + "data": { + "access_key_id": "aaa", + "secret_access_key": "aaa" + } + } + BODY + end + it 'the issuers are returned' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer_1, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer_2, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + + get("/issuers/rspec", + env: token_auth_header(role: admin_user)) + assert_response :success + parsed_body = JSON.parse(response.body) + expect(parsed_body["issuers"].length).to eq(2) + + expect(parsed_body["issuers"][0]["id"]).to eq("issuer-1") + expect(parsed_body["issuers"][0]["max_ttl"]).to eq(200) + expect(parsed_body["issuers"][0]["type"]).to eq("aws") + expect(parsed_body["issuers"][0]["data"]["access_key_id"]).to eq("a") + expect(parsed_body["issuers"][0]["data"]["secret_access_key"]).to eq("a") + + expect(parsed_body["issuers"][1]["id"]).to eq("issuer-2") + expect(parsed_body["issuers"][1]["max_ttl"]).to eq(300) + expect(parsed_body["issuers"][1]["type"]).to eq("aws") + expect(parsed_body["issuers"][1]["data"]["access_key_id"]).to eq("aaa") + expect(parsed_body["issuers"][1]["data"]["secret_access_key"]).to eq("aaa") + end + end + + context "when a user lists the issuers, and there are no issuers" do + it 'the response is an object with an empty array' do + get("/issuers/rspec", + env: token_auth_header(role: admin_user)) + assert_response :success + expect(response.body).to eq("{\"issuers\":[]}") + end + end + + context "when a user that does not have permissions to list issuers" do + it 'the response is forbidden' do + get("/issuers/rspec", + env: token_auth_header(role: alice_user)) + assert_response :forbidden + expect(response.body).to eq("") + end + end + end +end diff --git a/spec/controllers/platforms_controller_spec.rb b/spec/controllers/platforms_controller_spec.rb deleted file mode 100644 index a387391d93..0000000000 --- a/spec/controllers/platforms_controller_spec.rb +++ /dev/null @@ -1,423 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -DatabaseCleaner.strategy = :truncation - -describe PlatformsController, type: :request do - let(:url_resource) { "/resources/rspec" } - before do - init_slosilo_keys("rspec") - # Load the base data/platforms policies into Conjur - - put( - '/policies/rspec/policy/root', - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => data_platforms_policy - ) - ) - assert_response :success - - end - - let(:data_platforms_policy) do - <<~POLICY - - !policy - id: data/platforms - body: [] - POLICY - end - - let(:admin_user) { Role.find_or_create(role_id: 'rspec:user:admin') } - let(:current_user) { Role.find_or_create(role_id: current_user_id) } - let(:current_user_id) { 'rspec:user:admin' } - let(:alice_user) { Role.find_or_create(role_id: alice_user_id) } - let(:alice_user_id) { 'rspec:user:alice' } - - describe "#create" do - context "when a user sends body with id only" do - let(:payload_create_platforms_only_id) do - <<~BODY - { "id": "new-platform" } - BODY - end - it 'returns bad request' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platforms_only_id, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :bad_request - end - end - - context "when user sends body with id, max_ttl, type and data" do - let(:payload_create_platforms_complete_input) do - <<~BODY - { - "id": "aws-platform-1", - "max_ttl": 3000, - "type": "aws", - "data": { - "access_key_id": "my-key-id", - "access_key_secret": "my-key-secret" - } - } - BODY - end - it 'it returns created' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platforms_complete_input, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - parsed_body = JSON.parse(response.body) - expect(parsed_body["id"]).to eq("aws-platform-1") - expect(parsed_body["max_ttl"]).to eq(3000) - expect(parsed_body["type"]).to eq("aws") - expect(parsed_body["data"]["access_key_id"]).to eq("my-key-id") - expect(parsed_body["data"]["access_key_secret"]).to eq("my-key-secret") - expect(response.body).to include("\"created_at\"") - expect(response.body).to include("\"modified_at\"") - expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-1")).not_to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-1/delegation")).not_to eq(nil) - expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-1/delegation/consumers")).not_to eq(nil) - expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-1/delegation/secrets-creators")).not_to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-1/secrets")).not_to eq(nil) - expect(Resource.find(resource_id: "rspec:variable:data/platforms/aws-platform-1/secrets/default")).not_to eq(nil) - end - end - - context "when user creates a policy with unsupported symbols in its name" do - let(:payload_create_platforms_symbols_input) do - <<~BODY - { - "id": "aws-platform-!@\#$%^*()[]", - "max_ttl": 3000, - "type": "aws", - "data": { - "access_key_id": "my-key-id", - "access_key_secret": "my-key-secret" - } - } - BODY - end - it 'it returns created' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platforms_symbols_input, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :bad_request - parsed_body = JSON.parse(response.body) - expect(parsed_body["error"]["code"]).to eq("bad_request") - expect(parsed_body["error"]["message"]).to eq("id param only supports alpha numeric characters and +-_") - expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-!@#$%^*()[]")).to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-!@#$%^*()[]/delegation")).to eq(nil) - expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-!@#$%^*()[]/delegation/consumers")).to eq(nil) - expect(Resource.find(resource_id: "rspec:group:data/platforms/aws-platform-!@#$%^*()[]/delegation/secrets-creators")).to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:data/platforms/aws-platform-!@#$%^*()[]/secrets")).to eq(nil) - expect(Resource.find(resource_id: "rspec:variable:data/platforms/aws-platform-!@#$%^*()[]/secrets/default")).to eq(nil) - end - end - - context "when user sends an empty body" do - let(:payload_empty) do - <<~BODY - { - } - BODY - end - it 'it returns bad_request' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_empty, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :bad_request - end - end - - context "when user sends an empty id" do - let(:payload_blank_id) do - <<~BODY - { - "id": "" - } - BODY - end - it "it returns bad_request" do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_blank_id, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :bad_request - - end - end - - context "when user sends a valid creation request but without permissions" do - let(:payload_create_platforms_valid_input) do - <<~BODY - { - "id": "valid-platform", - "max_ttl": 1000, - "type": "aws", - "data": { - "access_key_id": "my-key-id", - "access_key_secret": "my-key-secret" - } - } - BODY - end - it 'returns forbidden' do - post("/platforms/rspec", - env: token_auth_header(role: alice_user).merge( - 'RAW_POST_DATA' => payload_create_platforms_valid_input, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :forbidden - expect(response.body).to eq("") - end - end - end - - describe "#delete" do - context "when a user deletes a platform that does not exist" do - it 'it returns not found' do - delete("/platforms/rspec/non-existing-platform", - env: token_auth_header(role: admin_user)) - assert_response :not_found - expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"non-existing-platform\"}}}") - end - end - - context "when a user deletes an existing platform" do - let(:payload_create_platform) do - <<~BODY - { - "id": "my-new-aws-platform", - "max_ttl": 200, - "type": "aws", - "data": { - "access_key_id": "aaa", - "access_key_secret": "a" - } - } - BODY - end - it 'it is deleted successfully' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platform, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - - delete("/platforms/rspec/my-new-aws-platform", env: token_auth_header(role: admin_user)) - assert_response :success - expect(Resource.find(resource_id: "rspec:policy:data/platforms/my-new-aws-platform")).to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:data/platforms/my-new-aws-platform/delegation")).to eq(nil) - expect(Resource.find(resource_id: "rspec:group:data/platforms/my-new-aws-platform/delegation/consumers")).to eq(nil) - expect(Resource.find(resource_id: "rspec:group:data/platforms/my-new-aws-platform/delegation/secrets-creators")).to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:data/platforms/my-new-aws-platform/secrets")).to eq(nil) - expect(Resource.find(resource_id: "rspec:variable:data/platforms/my-new-aws-platform/secrets/default")).to eq(nil) - end - - context "when a user deletes a non existing platform without permissions" do - it 'it returns not found' do - delete("/platforms/rspec/non-existing-platform", env: token_auth_header(role: alice_user)) - assert_response :not_found - end - end - end - - context "when a user tries to delete a platform without the correct permissions" do - let(:payload_create_platform) do - <<~BODY - { - "id": "platform-1", - "max_ttl": 200, - "type": "aws", - "data": { - "access_key_id": "a", - "access_key_secret": "a" - } - } - BODY - end - it 'it returns not found' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platform, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - - delete("/platforms/rspec/platform-1", - env: token_auth_header(role: alice_user)) - assert_response :not_found - expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"platform-1\"}}}") - end - end - end - - describe "#get" do - context "when a user gets a platform that exists" do - let(:payload_create_platform) do - <<~BODY - { - "id": "platform-1", - "max_ttl": 200, - "type": "aws", - "data": { - "access_key_id": "a", - "access_key_secret": "a" - } - } - BODY - end - it 'the platform is returned' do - - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platform, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - - get("/platforms/rspec/platform-1", - env: token_auth_header(role: admin_user)) - assert_response :success - parsed_body = JSON.parse(response.body) - expect(parsed_body["id"]).to eq("platform-1") - expect(parsed_body["max_ttl"]).to eq(200) - expect(parsed_body["type"]).to eq("aws") - expect(parsed_body["data"]["access_key_id"]).to eq("a") - expect(parsed_body["data"]["access_key_secret"]).to eq("a") - expect(response.body).to include("\"created_at\"") - expect(response.body).to include("\"modified_at\"") - end - end - - context "when a user gets a platform that does not exist" do - it 'the response is not found' do - get("/platforms/rspec/non-existing-platform", - env: token_auth_header(role: admin_user)) - assert_response :not_found - expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"non-existing-platform\"}}}") - end - end - - context "when a user that does not have permissions on platforms, gets a platform" do - let(:payload_create_platform) do - <<~BODY - { - "id": "platform-1", - "max_ttl": 200, - "type": "aws", - "data": { - "access_key_id": "a", - "access_key_secret": "a" - } - } - BODY - end - it 'the response is not found' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platform, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - - get("/platforms/rspec/platform-1", - env: token_auth_header(role: alice_user)) - assert_response :not_found - expect(response.body).to eq("{\"error\":{\"code\":\"not_found\",\"message\":\"Platform not found\",\"target\":null,\"details\":{\"code\":\"not_found\",\"target\":\"id\",\"message\":\"platform-1\"}}}") - end - end - end - - describe "#list" do - context "when a user lists the platforms" do - let(:payload_create_platform_1) do - <<~BODY - { - "id": "platform-1", - "max_ttl": 200, - "type": "aws", - "data": { - "access_key_id": "a", - "access_key_secret": "a" - } - } - BODY - end - let(:payload_create_platform_2) do - <<~BODY - { - "id": "platform-2", - "max_ttl": 300, - "type": "aws", - "data": { - "access_key_id": "aaa", - "access_key_secret": "aaa" - } - } - BODY - end - it 'the platforms are returned' do - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platform_1, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - post("/platforms/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_platform_2, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :created - - get("/platforms/rspec", - env: token_auth_header(role: admin_user)) - assert_response :success - parsed_body = JSON.parse(response.body) - expect(parsed_body["platforms"].length).to eq(2) - - expect(parsed_body["platforms"][0]["id"]).to eq("platform-1") - expect(parsed_body["platforms"][0]["max_ttl"]).to eq(200) - expect(parsed_body["platforms"][0]["type"]).to eq("aws") - expect(parsed_body["platforms"][0]["data"]["access_key_id"]).to eq("a") - expect(parsed_body["platforms"][0]["data"]["access_key_secret"]).to eq("a") - - expect(parsed_body["platforms"][1]["id"]).to eq("platform-2") - expect(parsed_body["platforms"][1]["max_ttl"]).to eq(300) - expect(parsed_body["platforms"][1]["type"]).to eq("aws") - expect(parsed_body["platforms"][1]["data"]["access_key_id"]).to eq("aaa") - expect(parsed_body["platforms"][1]["data"]["access_key_secret"]).to eq("aaa") - end - end - - context "when a user lists the platforms, and there are no platforms" do - it 'the response is an object with an empty array' do - get("/platforms/rspec", - env: token_auth_header(role: admin_user)) - assert_response :success - expect(response.body).to eq("{\"platforms\":[]}") - end - end - - context "when a user that does not have permissions to list platforms" do - it 'the response is forbidden' do - get("/platforms/rspec", - env: token_auth_header(role: alice_user)) - assert_response :forbidden - expect(response.body).to eq("") - end - end - end -end From 78c1b8ea61fd1ea7c358210540d92df0cae022c4 Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Sun, 27 Aug 2023 17:48:42 +0300 Subject: [PATCH 094/665] Updated the CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde29b9b6c..15c2213e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. (and update the corresponding date), or add a new version. ## [1.0.6-cloud] - 2023-08-21 +### Changed +- Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret + [ONYX-42993](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42993) ## [1.0.5-cloud] - 2023-08-16 ### Security From 71d3aeea877d356c4f6b83b728ba7e92a6d3919c Mon Sep 17 00:00:00 2001 From: Rafi Schwarz Date: Mon, 28 Aug 2023 15:40:20 +0300 Subject: [PATCH 095/665] Renamed the annotation sub parameter id to issuer --- app/controllers/secrets_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/secrets_controller.rb b/app/controllers/secrets_controller.rb index 4e9b24a5b7..7d548a6060 100644 --- a/app/controllers/secrets_controller.rb +++ b/app/controllers/secrets_controller.rb @@ -179,12 +179,12 @@ def handle_ephemeral_secret variable_data[issuer_param] = annotation.value end - issuer = Issuer.where(account: account, issuer_id: variable_data["id"]).first + issuer = Issuer.first(account: account, issuer_id: variable_data["issuer"]) # There shouldn't be a state where a variable belongs to an issuer that doesn't exit, but we check it to be safe raise ApplicationController::InternalServerError, "Issuer assigned to #{account}:#{params[:kind]}:#{params[:identifier]} was not found" unless issuer - logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(variable_data["id"], issuer.issuer_type, variable_data["method"], request_id)) + logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(variable_data["issuer"], issuer.issuer_type, variable_data["method"], request_id)) issuer_data = { max_ttl: issuer.max_ttl, From 5b0f1493cab2af001cd3c24f7fa34b278612fec8 Mon Sep 17 00:00:00 2001 From: nofarvered Date: Sun, 27 Aug 2023 00:41:32 +0300 Subject: [PATCH 096/665] authenticators replication ep with shared prop --- CHANGELOG.md | 1 + .../edge_authenticators_controller.rb | 62 +++++++ .../authenticators/authenticators_manager.rb | 59 +++++++ app/domain/logs.rb | 6 + app/models/authenticator.rb | 31 ++++ config/routes.rb | 3 + .../features/edge_jwt_replicaation.feature | 157 ++++++++++++++++++ 7 files changed, 319 insertions(+) create mode 100644 app/controllers/edge/internal/edge_authenticators_controller.rb create mode 100644 app/domain/edge_logic/authenticators/authenticators_manager.rb create mode 100644 app/models/authenticator.rb create mode 100644 cucumber/api/features/edge_jwt_replicaation.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c2213e34..67fb7f61a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret [ONYX-42993](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42993) +- get all authenticators endpoint, will be used by edge for replication. ## [1.0.5-cloud] - 2023-08-16 ### Security diff --git a/app/controllers/edge/internal/edge_authenticators_controller.rb b/app/controllers/edge/internal/edge_authenticators_controller.rb new file mode 100644 index 0000000000..1d8b8d8293 --- /dev/null +++ b/app/controllers/edge/internal/edge_authenticators_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative '../../../domain/edge_logic/authenticators/authenticators_manager' + +class EdgeAuthenticatorsController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + include AuthenticatorsManager + def all_authenticators + logger.info(LogMessages::Endpoints::EndpointRequested.new("all-authenticators")) + allowed_params = %i[account kind limit offset count] + options = params.permit(*allowed_params).to_h.symbolize_keys + + begin + verify_edge_host(options) + verify_kind(options[:kind]) + kinds = options[:kind].split(',') + scope = get_authenticators_data(kinds) + if params[:count] == 'true' + count=params[:count] + count_authenticators={} + scope.each do |authenticator_kind, authenticators_list| + count_authenticators[authenticator_kind] = authenticators_list.size + end + results = { count: count_authenticators } + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithCount.new("all-authenticators", count)) + render(json: results) + else + verify_header(request) + response.set_header("Content-Encoding", "base64") + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all-authenticators")) + render(json: scope) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + rescue => e + raise ApplicationController::InternalServerError, e.message + end + end + + private + def verify_kind(kinds_param) + allowed_kind = ['authn-jwt'] + kinds = kinds_param.split(',') + unless kinds.all? { |value| allowed_kind.include?(value) } + raise ArgumentError , "authenticator kind parameter is not valid" + end + end + def verify_header(request) + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + unless accepts_base64 + raise InternalServerError , "the header request must contain base64 accept-encoding" + end + end + +end diff --git a/app/domain/edge_logic/authenticators/authenticators_manager.rb b/app/domain/edge_logic/authenticators/authenticators_manager.rb new file mode 100644 index 0000000000..19e769ba45 --- /dev/null +++ b/app/domain/edge_logic/authenticators/authenticators_manager.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +module AuthenticatorsManager + def get_authenticators_data(kinds) + return_json = {} + kinds.each do |kind| + if kind == "authn-jwt" + return_json[kind] = authn_jwt_handler + end + end + return_json + end + + def authn_jwt_handler + results = [] + begin + authenticators = Authenticator.jwt + authenticators.each do |authenticator| + authenticatorToReturn = {} + authenticatorToReturn[:id] = authenticator[:resource_id] + if verify_path(authenticator[:resource_id]) + next + end + authenticatorToReturn[:enabled] = authenticator[:enabled] + authenticatorToReturn[:permissions] = nil + if JSON.parse(authenticator[:permissions]).first["role_id"]!=nil + authenticatorToReturn[:permissions] = [] + JSON.parse(authenticator[:permissions]).each do |row| + permissionToReturn = {} + permissionToReturn[:role] = row["role_id"] + permissionToReturn[:privilege] = row["privilege"] + authenticatorToReturn[:permissions] << permissionToReturn + end + authenticatorToReturn[:permissions].sort_by { |item| item[:privilege] } + end + authenticatorToReturn[:jwksUri] = nil + authenticatorToReturn[:publicKeys] = nil + authenticatorToReturn[:caCert] = nil + authenticatorToReturn[:tokenAppProperty] = nil + authenticatorToReturn[:identityPath] = nil + authenticatorToReturn[:issuer] = nil + authenticatorToReturn[:enforcedClaims] = nil + authenticatorToReturn[:claimAliases] = nil + authenticatorToReturn[:audience] = nil + results << authenticatorToReturn + end + rescue => e + raise InternalServerError, e.message + end + results + end + + private + + def verify_path(resource_id) + # we want to verify the authenticator is only two levels under root/conjur in the policy tree + # otherwise it is not a valid authenticator. + resource_id.length - resource_id.delete('/').length>2 + end +end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 0662be46c4..1d42e9204b 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -50,6 +50,12 @@ module Endpoints msg: "{0} endpoint is finished successfully with {1}-limit {2}-offset", code: "CONJ00155I" ) + + EndpointFinishedSuccessfullyWithCount = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully with {1}-count", + code: "CONJ00157I" + ) + end module Edge diff --git a/app/models/authenticator.rb b/app/models/authenticator.rb new file mode 100644 index 0000000000..e87d7e8f1a --- /dev/null +++ b/app/models/authenticator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Authenticator + class << self + def jwt + authn_jwt_prefix = "webservice:conjur/authn-jwt/" + resources_with_authenticator_configs = "SELECT resource_id, enabled + FROM authenticator_configs" + + # join between 3 tables : authn_configs, resources, permissions + # for getting resource_id, list of permissions for each resource_id and enabled + # if there is no record_id in authn_configs -> enabled will be false + # also, we will select resource_id only if it contains authn_jwt_prefix + # and he have only two "/" character - which means it is only one policy level under authn_jwt + scope = Sequel::Model.db.fetch(%{ + SELECT "resources"."resource_id", + jsonb_agg(jsonb_build_object( + 'privilege', "permissions"."privilege", + 'role_id', "permissions"."role_id")) AS "permissions", + COALESCE("authn_configs"."enabled", false) AS "enabled" + FROM "resources" + LEFT JOIN "permissions" ON "resources"."resource_id" = "permissions"."resource_id" + LEFT JOIN + (#{resources_with_authenticator_configs}) AS "authn_configs" ON "resources"."resource_id" = "authn_configs"."resource_id" + WHERE "resources"."resource_id" LIKE '%#{authn_jwt_prefix}%' + GROUP BY "resources"."resource_id", "authn_configs"."enabled"; + }) + scope + end + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index fbf291755f..271ea150db 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,8 +79,11 @@ def matches?(request) post "/secrets/:account/:kind/*identifier" => 'secrets#create' get "/secrets" => 'secrets#batch' post "/edge/:account" => 'edge_creation#create_edge' + get "/edge/secrets/:account" => 'edge_secrets#all_secrets' get "/edge/hosts/:account" => 'edge_hosts#all_hosts' + get "/edge/authenticators/:account" => 'edge_authenticators#all_authenticators' + get "edge/edge-creds/:account/:edge_name" => 'edge_creation#generate_install_token' get "/edge/:account" => 'edge_visibility#all_edges' get "/edge/max-allowed/:account" => 'edge_configuration#max_edges_allowed' diff --git a/cucumber/api/features/edge_jwt_replicaation.feature b/cucumber/api/features/edge_jwt_replicaation.feature new file mode 100644 index 0000000000..5bfe3f0c74 --- /dev/null +++ b/cucumber/api/features/edge_jwt_replicaation.feature @@ -0,0 +1,157 @@ +@api +Feature: Replicate jwt authenticators from edge endpoint + + Background: + Given I create a new user "some_user" + And I create a new user "admin_user" + And I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + - !policy + id: edge-configuration + body: + - &edge-variables + - !variable max-edge-allowed + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + - !policy + id: conjur/authn-jwt/myVendor + body: + - !webservice + - !variable jwks-uri + - !variable ca-cert + - !variable token-app-property + - !variable identity-path + - !variable issuer + - !variable enforced-claims + - !variable claim-aliases + - !variable audience + - !group apps + - !permit + role: !group apps + privilege: [read, authenticate] + resource: !webservice + - !webservice status + - !group operators + - !permit + role: !group operators + privilege: [read] + resource: !webservice status + - !policy + id: conjur/authn-jwt/withoutPermissions + body: + - !webservice + - !variable jwks-uri + - !variable ca-cert + """ + And I add the secret value "https://www.googleapis.com/oauth2/v3/certs" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/jwks-uri" + And I add the secret value "app_name" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/token-app-property" + And I add the secret value "data/myspace/jwt-apps" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/identity-path" + And I add the secret value "https://login.example.com" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/issuer" + And I add the secret value "additional_data/group_id" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/enforced-claims" + And I successfully PATCH "/authn-jwt/myVendor/cucumber" with body: + """ + enabled=true + """ + And I log out + + @acceptance + Scenario: Fetching all authenticators with edge host and Accept-Encoding base64 header return 200 OK + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I successfully GET "/edge/authenticators/cucumber?kind=authn-jwt" + Then the HTTP response status code is 200 + And the JSON should be: + """ +{ + "authn-jwt": [ + { + "id": "cucumber:webservice:conjur/authn-jwt/myVendor", + "enabled": true, + "permissions": [ + { + "privilege": "authenticate", + "role": "cucumber:group:conjur/authn-jwt/myVendor/apps" + }, + { + "privilege": "read", + "role": "cucumber:group:conjur/authn-jwt/myVendor/apps" + } + ], + "jwksUri": null, + "publicKeys": null, + "caCert": null, + "tokenAppProperty": null, + "identityPath": null, + "issuer": null, + "enforcedClaims": null, + "claimAliases": null, + "audience": null + }, + { + "id": "cucumber:webservice:conjur/authn-jwt/withoutPermissions", + "enabled": false, + "permissions": null, + "jwksUri": null, + "publicKeys": null, + "caCert": null, + "tokenAppProperty": null, + "identityPath": null, + "issuer": null, + "enforcedClaims": null, + "claimAliases": null, + "audience": null + } + ] + } + """ + + @negative + Scenario: Fetching authenticators with non edge host return 403 error + Given I login as "some_user" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber?kind=authn-jwt" + Then the HTTP response status code is 403 + Given I am the super-user + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber?kind=authn-jwt" + Then the HTTP response status code is 403 + + @negative + Scenario: Fetching all authenticators with edge host and without Accept-Encoding base64 header and return 500 + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/authenticators/cucumber?kind=authn-jwt" + Then the HTTP response status code is 500 + + @acceptance + Scenario: Fetching authenticators with invalid kind and return 422 + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber?kind=authn-something" + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching authenticators count + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/authenticators/cucumber?kind=authn-jwt&count=true" + And the JSON should be: + """ + { + "count": { + "authn-jwt": 2 + } + } + """ From 00aec97dc9776ed0c505292e88c849023b94ba32 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Thu, 31 Aug 2023 09:59:28 +0300 Subject: [PATCH 097/665] Phase 2 --- app/models/loader/orchestrate.rb | 9 ++++---- app/models/loader/types.rb | 35 +++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index f9666d302b..83383b864e 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -422,13 +422,13 @@ def perform_deletion # rubocop:enable Style/GuardClause end + $basic_schema = "" + # Loads the records into the temporary schema (since the schema search path # contains only the temporary schema). def load_records raise "Policy version must be saved before loading" unless policy_version.resource_id - create_records.map(&:create!) - db[:role_memberships].where(admin_option: nil).update(admin_option: false) db[:role_memberships].where(ownership: nil).update(ownership: false) TABLES.each do |table| @@ -451,15 +451,14 @@ def in_primary_schema &block # Creates a set of tables in the new schema to mirror the tables in the primary schema. # The new tables are not created with constraints, aside from primary keys. def create_schema + $basic_schema = db.search_path + db.execute("CREATE SCHEMA #{schema_name}") db.search_path = schema_name - TABLES.each do |table| db.execute("CREATE TABLE #{table} AS SELECT * FROM #{qualify_table(table)} WHERE 0 = 1") end - db.execute(Functions.ownership_trigger_sql) - db.execute(<<-SQL_STATEMENT) CREATE OR REPLACE FUNCTION account(id text) RETURNS text LANGUAGE sql IMMUTABLE diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index 07ae694492..ad30ebcf54 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true + module Loader module Types + class << self def find_or_create_root_policy(account) ::Resource[root_policy_id(account)] || create_root_policy(account) @@ -283,7 +285,38 @@ class Variable < Record def_delegators :@policy_object, :kind, :mime_type - def verify; end + def verify; + Rails.logger.info("+++++++++++++++ verify Variable 1") + Rails.logger.info("+++++++++++++++ verify Variable 2 self.annotations = #{self.annotations}, self.id = #{self.id}, self.resource = #{self.resource}") + + if self.id.start_with?("data/ephemerals") + Rails.logger.info("+++++++++++++++ verify Variable 3") + if self.annotations["issuer"].nil? + message = "Ephemeral variable #{self.id} has no issuer annotation" + raise Exceptions::InvalidPolicyObject.new(self.id, message: message) + else + issuer_id = self.annotations["issuer"] + Rails.logger.info("+++++++++++++++ verify Variable 4 issuer_id = #{issuer_id}") + + Rails.logger.info("+++++++++++++ verify public Variable 4.4 Sequel::Model.db.search_path = #{Sequel::Model.db.search_path}") + Rails.logger.info("+++++++++++++ verify public Variable 4.4.1 Issuer.db.search_path = #{Issuer.db.search_path}") + Rails.logger.info("+++++++++++++ verify public Variable 4.4.2 $basic_schema = #{$basic_schema}") + current_schema = Issuer.db.search_path + Issuer.db.search_path = $basic_schema + Rails.logger.info("+++++++++++++++ verify public Variable 4.5") + issuer = Issuer.where(account: "conjur", issuer_id: issuer_id).first + Rails.logger.info("+++++++++++++++ verify public Variable 4.6 issuer = #{issuer}") + Issuer.db.search_path = current_schema + if (issuer.nil?) + message = "Ephemeral variable #{self.id} issuer #{issuer_id} is not defined" + raise Exceptions::InvalidPolicyObject.new(self.id, message: message) + else + Rails.logger.info("+++++++++++++++ verify Variable 5 issuer = #{issuer}") + end + + end + end + end def create! self.annotations ||= {} From 36fb8867a967b4ecbfc4afe7e2ca1033a09f0e14 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Thu, 31 Aug 2023 11:34:10 +0300 Subject: [PATCH 098/665] Working --- app/models/loader/types.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index ad30ebcf54..204b670e58 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -291,11 +291,11 @@ def verify; if self.id.start_with?("data/ephemerals") Rails.logger.info("+++++++++++++++ verify Variable 3") - if self.annotations["issuer"].nil? + if self.annotations["ephemerals/issuer"].nil? message = "Ephemeral variable #{self.id} has no issuer annotation" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) else - issuer_id = self.annotations["issuer"] + issuer_id = self.annotations["ephemerals/issuer"] Rails.logger.info("+++++++++++++++ verify Variable 4 issuer_id = #{issuer_id}") Rails.logger.info("+++++++++++++ verify public Variable 4.4 Sequel::Model.db.search_path = #{Sequel::Model.db.search_path}") @@ -310,11 +310,17 @@ def verify; if (issuer.nil?) message = "Ephemeral variable #{self.id} issuer #{issuer_id} is not defined" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) - else - Rails.logger.info("+++++++++++++++ verify Variable 5 issuer = #{issuer}") end end + else + Rails.logger.info("+++++++++++++++ verify public Variable 5") + if !(self.annotations.nil?) && !(self.annotations["ephemerals/issuer"].nil?) + Rails.logger.info("+++++++++++++++ verify public Variable 6") + message = "Regular variable #{self.id} issuer is defined" + raise Exceptions::InvalidPolicyObject.new(self.id, message: message) + end + Rails.logger.info("+++++++++++++++ verify public Variable 7") end end From 15755b8cdb92d6f92b640b0c26b94c024cc167d7 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Thu, 31 Aug 2023 12:12:30 +0300 Subject: [PATCH 099/665] Adding tests --- spec/models/loader/types.rb | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 65a07fbd62..5445c5a740 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -147,3 +147,46 @@ end end + +describe Loader::Types::Variable do + let(:variable) do + variable = Conjur::PolicyParser::Types::Variable.new + variable.id = resource_id + if issuer != '' + variable.annotations = { "ephemerals/issuer" => issuer } + end + Loader::Types.wrap(variable, self) + end + + describe '.verify' do + context 'when CONJUR_AUTHN_API_KEY_DEFAULT is true' do + before do + allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(true) + end + + context 'when creating regular variable without ephemerals/issuer annotation' do + let(:resource_id) { 'data/myvar1' } + let(:issuer) { '' } + it { expect { variable.verify }.to_not raise_error } + end + + context 'when creating regular variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/myvar2' } + let(:issuer) { 'aws1' } + it { expect { variable.verify }.to raise_error } + end + + context 'when creating ephemeral variable without ephemerals/issuer annotation' do + let(:resource_id) { 'data/ephemerals/myvar1' } + let(:issuer) { '' } + it { expect { variable.verify }.to raise_error } + end + + #context 'when creating ephemeral variable with ephemerals/issuer annotation' do + # let(:resource_id) { 'data/ephemerals/myvar2' } + # let(:issuer) { 'aws1' } + # it { expect { variable.verify }.to_not raise_error } + #end + end + end +end From 83e12aff3128b7df5e71630244c35e18035a8af3 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Thu, 31 Aug 2023 15:33:03 +0300 Subject: [PATCH 100/665] Authorization working --- app/models/loader/types.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index 204b670e58..1c10748519 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true - +require '/opt/conjur-server/app/controllers/concerns/authorize_resource' module Loader module Types @@ -282,6 +282,7 @@ def create! class Variable < Record include CreateResource + include AuthorizeResource def_delegators :@policy_object, :kind, :mime_type @@ -311,7 +312,14 @@ def verify; message = "Ephemeral variable #{self.id} issuer #{issuer_id} is not defined" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end + Rails.logger.info("+++++++++++++++ verify public Variable 4.7") + + Sequel::Model.db.search_path = $basic_schema + resource = ::Resource["conjur:policy:conjur/issuers/" + issuer_id] + authorize(:use, resource) + Sequel::Model.db.search_path = current_schema + Rails.logger.info("+++++++++++++++ verify public Variable 4.8") end else Rails.logger.info("+++++++++++++++ verify public Variable 5") From e4900f19cb160c72657636c94a2720452b4d6244 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Thu, 31 Aug 2023 16:11:25 +0300 Subject: [PATCH 101/665] Tests 1 --- app/models/loader/types.rb | 3 ++- spec/models/loader/types.rb | 32 +++++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index 1c10748519..dcc4e5fe64 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -require '/opt/conjur-server/app/controllers/concerns/authorize_resource' +#require '/opt/conjur-server/app/controllers/concerns/authorize_resource' +require_relative '../../controllers/concerns/authorize_resource' module Loader module Types diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 5445c5a740..0f081cfccb 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -159,9 +159,9 @@ end describe '.verify' do - context 'when CONJUR_AUTHN_API_KEY_DEFAULT is true' do + context 'when no issuer configured' do before do - allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(true) + #allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(true) end context 'when creating regular variable without ephemerals/issuer annotation' do @@ -182,11 +182,29 @@ it { expect { variable.verify }.to raise_error } end - #context 'when creating ephemeral variable with ephemerals/issuer annotation' do - # let(:resource_id) { 'data/ephemerals/myvar2' } - # let(:issuer) { 'aws1' } - # it { expect { variable.verify }.to_not raise_error } - #end + context 'when creating ephemeral variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/ephemerals/myvar2' } + let(:issuer) { 'aws1' } + it { expect { variable.verify }.to raise_error } + end + end + + context 'when issuer aws1 configured without permissions' do + before do + #allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(true) + end + + context 'when creating regular variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/myvar2' } + let(:issuer) { 'aws1' } + it { expect { variable.verify }.to raise_error } + end + + context 'when creating ephemeral variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/ephemerals/myvar2' } + let(:issuer) { 'aws1' } + it { expect { variable.verify }.to raise_error } + end end end end From 90bcd0c14a91bcddda970146518d973cbd9cf083 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Sun, 3 Sep 2023 09:00:21 +0300 Subject: [PATCH 102/665] Tests 2 --- app/models/loader/types.rb | 1 - spec/models/loader/types.rb | 45 ++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index dcc4e5fe64..8cec1d7bd6 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -#require '/opt/conjur-server/app/controllers/concerns/authorize_resource' require_relative '../../controllers/concerns/authorize_resource' module Loader diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 0f081cfccb..7c081e7343 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -191,20 +191,59 @@ context 'when issuer aws1 configured without permissions' do before do - #allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(true) + allow(Issuer).to receive(:where).with("conjur", issuer_id).and_return(issuer_object) end context 'when creating regular variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/myvar2' } - let(:issuer) { 'aws1' } + let(:issuer_id) { 'aws1' } + let(:issuer_object) { nil } it { expect { variable.verify }.to raise_error } end context 'when creating ephemeral variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar2' } - let(:issuer) { 'aws1' } + let(:issuer_id) { 'aws1' } + let(:issuer_object) { nil } + it { expect { variable.verify }.to raise_error } + end + + context 'when creating ephemeral variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/ephemerals/myvar2' } + let(:issuer_id) { 'aws1' } + let(:issuer_object) { 'issuer' } + it { expect { variable.verify }.to raise_error } + end + end + + context 'when issuer aws1 configured with permissions' do + before do + allow(Issuer).to receive(:where).with("conjur", issuer_id).and_return(issuer_object) + #allow(authorize).to receive(:use, :resource) + # .and_raise('Forbidden') + end + + context 'when creating regular variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/myvar2' } + let(:issuer_id) { 'aws1' } + let(:issuer_object) { nil } + it { expect { variable.verify }.to raise_error } + end + + context 'when creating ephemeral variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/ephemerals/myvar2' } + let(:issuer_id) { 'aws1' } + let(:issuer_object) { nil } + it { expect { variable.verify }.to raise_error } + end + + context 'when creating ephemeral variable with ephemerals/issuer annotation' do + let(:resource_id) { 'data/ephemerals/myvar2' } + let(:issuer_id) { 'aws1' } + let(:issuer_object) { 'issuer' } it { expect { variable.verify }.to raise_error } end end + end end From 8033c398555003752282e4e5076748c2068cab85 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Sun, 3 Sep 2023 10:30:57 +0300 Subject: [PATCH 103/665] Tests --- app/models/loader/types.rb | 12 +++++++++--- spec/models/loader/types.rb | 25 ++++++++++++++----------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index 8cec1d7bd6..a5af882715 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -107,12 +107,19 @@ def resource class Record < Types::Base include CreateRole include CreateResource + include AuthorizeResource def verify message = "Verify method for entity #{self} does not exist" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end + def auth_resource privilege, resource_id + Rails.logger.info("+++++++++++++ auth_resource privilege = #{privilege}, resource_id = #{resource_id}") + resource = ::Resource[resource_id] + authorize(privilege, resource) + end + def calculate_defaults!; end def create! @@ -282,7 +289,6 @@ def create! class Variable < Record include CreateResource - include AuthorizeResource def_delegators :@policy_object, :kind, :mime_type @@ -316,8 +322,8 @@ def verify; Sequel::Model.db.search_path = $basic_schema - resource = ::Resource["conjur:policy:conjur/issuers/" + issuer_id] - authorize(:use, resource) + resource_id = "conjur:policy:conjur/issuers/" + issuer_id + auth_resource(:use, resource_id) Sequel::Model.db.search_path = current_schema Rails.logger.info("+++++++++++++++ verify public Variable 4.8") end diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 7c081e7343..146652d0d2 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -152,8 +152,8 @@ let(:variable) do variable = Conjur::PolicyParser::Types::Variable.new variable.id = resource_id - if issuer != '' - variable.annotations = { "ephemerals/issuer" => issuer } + if issuer_id != '' + variable.annotations = { "ephemerals/issuer" => issuer_id } end Loader::Types.wrap(variable, self) end @@ -161,37 +161,38 @@ describe '.verify' do context 'when no issuer configured' do before do - #allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(true) + $basic_schema = "public" end context 'when creating regular variable without ephemerals/issuer annotation' do let(:resource_id) { 'data/myvar1' } - let(:issuer) { '' } + let(:issuer_id) { '' } it { expect { variable.verify }.to_not raise_error } end context 'when creating regular variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/myvar2' } - let(:issuer) { 'aws1' } + let(:issuer_id) { 'aws1' } it { expect { variable.verify }.to raise_error } end context 'when creating ephemeral variable without ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar1' } - let(:issuer) { '' } + let(:issuer_id) { '' } it { expect { variable.verify }.to raise_error } end context 'when creating ephemeral variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar2' } - let(:issuer) { 'aws1' } + let(:issuer_id) { 'aws1' } it { expect { variable.verify }.to raise_error } end end context 'when issuer aws1 configured without permissions' do before do - allow(Issuer).to receive(:where).with("conjur", issuer_id).and_return(issuer_object) + allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}).and_return(issuer_object) + $basic_schema = "public" end context 'when creating regular variable with ephemerals/issuer annotation' do @@ -218,9 +219,10 @@ context 'when issuer aws1 configured with permissions' do before do - allow(Issuer).to receive(:where).with("conjur", issuer_id).and_return(issuer_object) - #allow(authorize).to receive(:use, :resource) - # .and_raise('Forbidden') + allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}) + .and_return(issuer_object) + allow(Conjur::PolicyParser::Types::Variable).to receive(:auth_resource).with({:privilege=>'use', resource_id=>:policy_resource}) + $basic_schema = "public" end context 'when creating regular variable with ephemerals/issuer annotation' do @@ -241,6 +243,7 @@ let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { 'issuer' } + #let(:policy_resource) { 'conjur:policy:conjur/issuers/aws1' } it { expect { variable.verify }.to raise_error } end end From 6c02326652bb7c4f41ed110ca04f581b036b3ff2 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Sun, 3 Sep 2023 13:47:16 +0300 Subject: [PATCH 104/665] Fixing code --- app/models/loader/types.rb | 41 +++++++++++++++----------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index a5af882715..5f718fde3a 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -109,13 +109,23 @@ class Record < Types::Base include CreateResource include AuthorizeResource + @current_schema = "" + + def lookup_committed + @current_schema = Issuer.db.search_path + Sequel::Model.db.search_path = $basic_schema + end + + def lookup_current + Sequel::Model.db.search_path = @current_schema + end + def verify message = "Verify method for entity #{self} does not exist" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end def auth_resource privilege, resource_id - Rails.logger.info("+++++++++++++ auth_resource privilege = #{privilege}, resource_id = #{resource_id}") resource = ::Resource[resource_id] authorize(privilege, resource) end @@ -123,7 +133,9 @@ def auth_resource privilege, resource_id def calculate_defaults!; end def create! + lookup_committed verify + lookup_current calculate_defaults! create_role! if policy_object.respond_to?(:roleid) create_resource! if policy_object.respond_to?(:resourceid) @@ -293,48 +305,27 @@ class Variable < Record def_delegators :@policy_object, :kind, :mime_type def verify; - Rails.logger.info("+++++++++++++++ verify Variable 1") - Rails.logger.info("+++++++++++++++ verify Variable 2 self.annotations = #{self.annotations}, self.id = #{self.id}, self.resource = #{self.resource}") - if self.id.start_with?("data/ephemerals") - Rails.logger.info("+++++++++++++++ verify Variable 3") if self.annotations["ephemerals/issuer"].nil? message = "Ephemeral variable #{self.id} has no issuer annotation" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) else issuer_id = self.annotations["ephemerals/issuer"] - Rails.logger.info("+++++++++++++++ verify Variable 4 issuer_id = #{issuer_id}") - - Rails.logger.info("+++++++++++++ verify public Variable 4.4 Sequel::Model.db.search_path = #{Sequel::Model.db.search_path}") - Rails.logger.info("+++++++++++++ verify public Variable 4.4.1 Issuer.db.search_path = #{Issuer.db.search_path}") - Rails.logger.info("+++++++++++++ verify public Variable 4.4.2 $basic_schema = #{$basic_schema}") - current_schema = Issuer.db.search_path - Issuer.db.search_path = $basic_schema - Rails.logger.info("+++++++++++++++ verify public Variable 4.5") - issuer = Issuer.where(account: "conjur", issuer_id: issuer_id).first - Rails.logger.info("+++++++++++++++ verify public Variable 4.6 issuer = #{issuer}") - Issuer.db.search_path = current_schema + + issuer = Issuer.where(account: @policy_object.account, issuer_id: issuer_id).first if (issuer.nil?) message = "Ephemeral variable #{self.id} issuer #{issuer_id} is not defined" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end - Rails.logger.info("+++++++++++++++ verify public Variable 4.7") - - Sequel::Model.db.search_path = $basic_schema - resource_id = "conjur:policy:conjur/issuers/" + issuer_id + resource_id = @policy_object.account + ":policy:conjur/issuers/" + issuer_id auth_resource(:use, resource_id) - Sequel::Model.db.search_path = current_schema - Rails.logger.info("+++++++++++++++ verify public Variable 4.8") end else - Rails.logger.info("+++++++++++++++ verify public Variable 5") if !(self.annotations.nil?) && !(self.annotations["ephemerals/issuer"].nil?) - Rails.logger.info("+++++++++++++++ verify public Variable 6") message = "Regular variable #{self.id} issuer is defined" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end - Rails.logger.info("+++++++++++++++ verify public Variable 7") end end From 96ee656002a39c5d6ff2813acce07465fb2c06f0 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Sun, 3 Sep 2023 15:09:56 +0300 Subject: [PATCH 105/665] Test running --- spec/models/loader/types.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 146652d0d2..8186cda2e3 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -152,6 +152,7 @@ let(:variable) do variable = Conjur::PolicyParser::Types::Variable.new variable.id = resource_id + variable.account = "conjur" if issuer_id != '' variable.annotations = { "ephemerals/issuer" => issuer_id } end @@ -221,7 +222,7 @@ before do allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}) .and_return(issuer_object) - allow(Conjur::PolicyParser::Types::Variable).to receive(:auth_resource).with({:privilege=>'use', resource_id=>:policy_resource}) + allow_any_instance_of(AuthorizeResource).to receive(:authorize).with(:use, nil) $basic_schema = "public" end @@ -243,8 +244,8 @@ let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { 'issuer' } - #let(:policy_resource) { 'conjur:policy:conjur/issuers/aws1' } - it { expect { variable.verify }.to raise_error } + let(:policy_resource) { 'conjur:policy:conjur/issuers/aws1' } + it { expect { variable.verify }.not_to raise_error } end end From fd225b87c2aa69cad8f87d1c50352c49b9a8bbbc Mon Sep 17 00:00:00 2001 From: nofarvered Date: Thu, 31 Aug 2023 00:59:24 +0300 Subject: [PATCH 106/665] authenticators replication ep with unique prop --- CHANGELOG.md | 9 +- .../authenticators/authenticators_manager.rb | 85 +++++++++++++++---- app/models/authenticator.rb | 83 ++++++++++++++---- .../internal/edge_jwt_replication.feature} | 53 +++++++++--- .../edge/internal/edge_secrets.feature | 2 +- 5 files changed, 184 insertions(+), 48 deletions(-) rename cucumber/api/features/{edge_jwt_replicaation.feature => edge/internal/edge_jwt_replication.feature} (70%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fb7f61a8..fe4ea631e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.6-cloud] - 2023-08-21 +## [1.0.6-cloud] - 2023-09-04 ### Changed - Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret [ONYX-42993](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42993) -- get all authenticators endpoint, will be used by edge for replication. +- Get all authenticators endpoint, will be used by edge for replication. +- Modify edge logs. + +## [1.0.6-cloud] - 2023-08-21 +### Changed +- Code refactoring ## [1.0.5-cloud] - 2023-08-16 ### Security diff --git a/app/domain/edge_logic/authenticators/authenticators_manager.rb b/app/domain/edge_logic/authenticators/authenticators_manager.rb index 19e769ba45..afe5a5e98d 100644 --- a/app/domain/edge_logic/authenticators/authenticators_manager.rb +++ b/app/domain/edge_logic/authenticators/authenticators_manager.rb @@ -12,15 +12,20 @@ def get_authenticators_data(kinds) def authn_jwt_handler results = [] + unique_properties = %w[jwksUri publicKeys caCert tokenAppProperty identityPath issuer enforcedClaims claimAliases audience] + unique_properties_name_in_policy = %w[jwks-uri public-keys ca-cert token-app-property identity-path issuer enforced-claims claim-aliases audience] + property_map = unique_properties_name_in_policy.zip(unique_properties).to_h begin authenticators = Authenticator.jwt authenticators.each do |authenticator| authenticatorToReturn = {} authenticatorToReturn[:id] = authenticator[:resource_id] - if verify_path(authenticator[:resource_id]) + unless validate_authenticator_path(authenticator[:resource_id]) next end + # if the the enable is not configured the default value will be false authenticatorToReturn[:enabled] = authenticator[:enabled] + # if there is no permissions for authenticator the default value will be nil authenticatorToReturn[:permissions] = nil if JSON.parse(authenticator[:permissions]).first["role_id"]!=nil authenticatorToReturn[:permissions] = [] @@ -30,30 +35,78 @@ def authn_jwt_handler permissionToReturn[:privilege] = row["privilege"] authenticatorToReturn[:permissions] << permissionToReturn end - authenticatorToReturn[:permissions].sort_by { |item| item[:privilege] } + authenticatorToReturn[:permissions] = authenticatorToReturn[:permissions].sort_by { |item| item[:privilege] } end - authenticatorToReturn[:jwksUri] = nil - authenticatorToReturn[:publicKeys] = nil - authenticatorToReturn[:caCert] = nil - authenticatorToReturn[:tokenAppProperty] = nil - authenticatorToReturn[:identityPath] = nil - authenticatorToReturn[:issuer] = nil - authenticatorToReturn[:enforcedClaims] = nil - authenticatorToReturn[:claimAliases] = nil - authenticatorToReturn[:audience] = nil - results << authenticatorToReturn + # set all the 8 unique properties to be nil by default + nil_properties_hash = unique_properties.map { |property| [property, nil] }.to_h + authenticatorToReturn.merge!(nil_properties_hash) + # if the property_id is in the claims list is means the claims configured + # if it configured in the policy the property value should be empty string + # if the property set to a new value, it should be the actual value + JSON.parse(authenticator[:claims]).each do |claim| + full_id = claim["property_id"] + key = full_id.split("/").last + key_mapping = property_map[key] + property_value = Authenticator.get_property(full_id) + if key_mapping == "enforcedClaims" + authenticatorToReturn[key_mapping] = build_enforced_claims(property_value) + elsif key_mapping == "claimAliases" + authenticatorToReturn[key_mapping] = build_claim_aliases(property_value) + else + authenticatorToReturn[key_mapping] = Base64.strict_encode64(property_value) + end end + results << authenticatorToReturn + end rescue => e raise InternalServerError, e.message end + # the authenticators are sorted by resource_id DESC results end private - - def verify_path(resource_id) - # we want to verify the authenticator is only two levels under root/conjur in the policy tree + def validate_authenticator_path(resource_id) + # Valid authenticator is only two levels under conjur policy in the policy tree # otherwise it is not a valid authenticator. - resource_id.length - resource_id.delete('/').length>2 + # valid : conjur/authn-jwt/myVendor + # not valid : conjur/authn-jwt/myVendor/status + resource_id.count("/") == 2 + end + + def build_enforced_claims(value) + # enforced-claims should be a array of strings seperated by comma + if value == "" + # if the value of enforced-claims is empty string it means it declared on the policy but never set a value. + # in such a case, by design, we need to return empty string. + value + else + result = value.split(',') + result = result.map { |item| Base64.strict_encode64(item) } + result + end end + + def build_claim_aliases(value) + # claim aliases should be a array of objects with "annotationName" : "claimName" structure + if value == "" + # if the value of claim aliases is empty string it means it declared on the policy but never set a value. + # in such a case, by design, we need to return empty string. + value + else + begin + result = value.split(',').map do |pair| + annotation, claim = pair.split(':') + { + "annotationName" => Base64.strict_encode64(annotation), + "claimName" => Base64.strict_encode64(claim) + } + end + rescue => e + raise InternalServerError, e.message + end + result + end + end + end diff --git a/app/models/authenticator.rb b/app/models/authenticator.rb index e87d7e8f1a..c4b19eaec8 100644 --- a/app/models/authenticator.rb +++ b/app/models/authenticator.rb @@ -2,29 +2,76 @@ class Authenticator class << self + + def get_property(id) + begin + record = Secret.where(:resource_id.like(id)).last.value + record + rescue + '' + end + end def jwt authn_jwt_prefix = "webservice:conjur/authn-jwt/" + variable_jwt_prefix = "variable:conjur/authn-jwt/" resources_with_authenticator_configs = "SELECT resource_id, enabled FROM authenticator_configs" - # join between 3 tables : authn_configs, resources, permissions - # for getting resource_id, list of permissions for each resource_id and enabled - # if there is no record_id in authn_configs -> enabled will be false - # also, we will select resource_id only if it contains authn_jwt_prefix - # and he have only two "/" character - which means it is only one policy level under authn_jwt - scope = Sequel::Model.db.fetch(%{ - SELECT "resources"."resource_id", - jsonb_agg(jsonb_build_object( - 'privilege', "permissions"."privilege", - 'role_id', "permissions"."role_id")) AS "permissions", - COALESCE("authn_configs"."enabled", false) AS "enabled" - FROM "resources" - LEFT JOIN "permissions" ON "resources"."resource_id" = "permissions"."resource_id" - LEFT JOIN - (#{resources_with_authenticator_configs}) AS "authn_configs" ON "resources"."resource_id" = "authn_configs"."resource_id" - WHERE "resources"."resource_id" LIKE '%#{authn_jwt_prefix}%' - GROUP BY "resources"."resource_id", "authn_configs"."enabled"; - }) + # # join between 3 tables : authn_configs, resources, permissions + # # for getting resource_id, list of permissions for each resource_id and enabled + # # if there is no record_id in authn_configs -> enabled will be false + # # also, we will select resource_id only if it contains authn_jwt_prefix + shared_properties = "SELECT resources.resource_id, + jsonb_agg(jsonb_build_object( + 'privilege', permissions.privilege, + 'role_id', permissions.role_id)) AS permissions, + COALESCE(authn_configs.enabled, false) AS enabled + FROM resources + LEFT JOIN permissions ON resources.resource_id = permissions.resource_id + LEFT JOIN + (#{resources_with_authenticator_configs}) AS authn_configs ON resources.resource_id = authn_configs.resource_id + WHERE resources.resource_id LIKE '%#{authn_jwt_prefix}%' + GROUP BY resources.resource_id, authn_configs.enabled" + + # extract all properties (secrets) that belongs to some authn-jwt + # on theirs latest version + unique_properties = "SELECT + r.resource_id AS property_id, + COALESCE(s.version, 1) AS version + FROM resources r + LEFT JOIN ( + SELECT + resource_id, + MAX(version) AS max_version + FROM secrets + GROUP BY + resource_id + ) subquery ON r.resource_id = subquery.resource_id + LEFT JOIN secrets s ON r.resource_id = s.resource_id AND subquery.max_version = s.version + WHERE r.resource_id LIKE '%#{variable_jwt_prefix}%'" + + # split_part(Shared.resource_id, '/', 3) = split_part(UniqueProps.property_id, '/', 3) + # explanation : each resource_id has some UniqueProps.property_id , we want to join by the following format + # cucumber:variable:conjur/authn-jwt/X/Y == cucumber:webservice:conjur/authn-jwt/X + # conjur/authn-jwt/X --> is the shared part to be joined by + scope = Sequel::Model.db.fetch(%{ + WITH Shared AS ( + #{shared_properties} + ), + UniqueProps AS ( + #{unique_properties} + ) + SELECT + Shared.resource_id, + Shared.permissions, + Shared.enabled, + jsonb_agg(jsonb_build_object( + 'property_id', UniqueProps.property_id)) AS claims + FROM Shared + LEFT JOIN UniqueProps ON split_part(Shared.resource_id, '/', 3) = split_part(UniqueProps.property_id, '/', 3) + GROUP BY Shared.resource_id, Shared.permissions, Shared.enabled + ORDER BY Shared.resource_id; + }) scope end end diff --git a/cucumber/api/features/edge_jwt_replicaation.feature b/cucumber/api/features/edge/internal/edge_jwt_replication.feature similarity index 70% rename from cucumber/api/features/edge_jwt_replicaation.feature rename to cucumber/api/features/edge/internal/edge_jwt_replication.feature index 5bfe3f0c74..cb6119dde3 100644 --- a/cucumber/api/features/edge_jwt_replicaation.feature +++ b/cucumber/api/features/edge/internal/edge_jwt_replication.feature @@ -62,6 +62,8 @@ Feature: Replicate jwt authenticators from edge endpoint And I add the secret value "data/myspace/jwt-apps" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/identity-path" And I add the secret value "https://login.example.com" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/issuer" And I add the secret value "additional_data/group_id" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/enforced-claims" + And I add the secret value "google/claim, azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/enforced-claims" + And I add the secret value "claim:google/claim, myclaim:azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" And I successfully PATCH "/authn-jwt/myVendor/cucumber" with body: """ enabled=true @@ -76,7 +78,7 @@ Feature: Replicate jwt authenticators from edge endpoint Then the HTTP response status code is 200 And the JSON should be: """ -{ + { "authn-jwt": [ { "id": "cucumber:webservice:conjur/authn-jwt/myVendor", @@ -91,23 +93,35 @@ Feature: Replicate jwt authenticators from edge endpoint "role": "cucumber:group:conjur/authn-jwt/myVendor/apps" } ], - "jwksUri": null, + "jwksUri": "aHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vb2F1dGgyL3YzL2NlcnRz", "publicKeys": null, - "caCert": null, - "tokenAppProperty": null, - "identityPath": null, - "issuer": null, - "enforcedClaims": null, - "claimAliases": null, - "audience": null + "caCert": "", + "tokenAppProperty": "YXBwX25hbWU=", + "identityPath": "ZGF0YS9teXNwYWNlL2p3dC1hcHBz", + "issuer": "aHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbQ==", + "enforcedClaims": [ + "Z29vZ2xlL2NsYWlt", + "IGF6dXJlL2NsYWlt" + ], + "claimAliases": [ + { + "annotationName": "Y2xhaW0=", + "claimName": "Z29vZ2xlL2NsYWlt" + }, + { + "annotationName": "IG15Y2xhaW0=", + "claimName": "YXp1cmUvY2xhaW0=" + } + ], + "audience": "" }, { "id": "cucumber:webservice:conjur/authn-jwt/withoutPermissions", "enabled": false, "permissions": null, - "jwksUri": null, + "jwksUri": "", "publicKeys": null, - "caCert": null, + "caCert": "", "tokenAppProperty": null, "identityPath": null, "issuer": null, @@ -155,3 +169,20 @@ Feature: Replicate jwt authenticators from edge endpoint } } """ + + @negative + Scenario: Fetching all authenticators with edge host and with not able to parse claim aliases by right structure and return 500 + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I add the secret value "google/claim, myclaim:azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber?kind=authn-jwt" + Then the HTTP response status code is 500 + + @negative + Scenario: Fetching all authenticators with edge host and with not able to parse claim aliases by right structure and return 500 + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I add the secret value "google/claim, myclaim::azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber?kind=authn-jwt" + Then the HTTP response status code is 500 + diff --git a/cucumber/api/features/edge/internal/edge_secrets.feature b/cucumber/api/features/edge/internal/edge_secrets.feature index 75542b3903..edf784588c 100644 --- a/cucumber/api/features/edge/internal/edge_secrets.feature +++ b/cucumber/api/features/edge/internal/edge_secrets.feature @@ -412,7 +412,7 @@ Feature: Fetching secrets from edge endpoint """ @negative @acceptance - Scenario: Fetching all secrets with edge host without Accept-Encoding base64 and special character secret, return 500 + Scenario: Fetching all secrets with edge host without Accept-Encoding base64 return 200 and I have special character secret Given I login as "some_user" And I add the secret value "s1±" to the resource "cucumber:variable:data/secret1" From d46bcf104a30cd5ee9d7a21648ecf24cd256d3eb Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Mon, 4 Sep 2023 14:57:50 +0300 Subject: [PATCH 107/665] ONYX-43857: Refactor host controller name to workload controller name --- .../{hosts_controller.rb => workload_controller.rb} | 6 +++--- ...hosts_controller_spec.rb => workload_controller_spec.rb} | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename app/controllers/{hosts_controller.rb => workload_controller.rb} (95%) rename spec/controllers/{hosts_controller_spec.rb => workload_controller_spec.rb} (99%) diff --git a/app/controllers/hosts_controller.rb b/app/controllers/workload_controller.rb similarity index 95% rename from app/controllers/hosts_controller.rb rename to app/controllers/workload_controller.rb index 43b273eebb..9ec4bffa39 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/workload_controller.rb @@ -2,7 +2,7 @@ require_relative '../controllers/wrappers/policy_wrapper' require_relative '../controllers/wrappers/policy_audit' # -class HostsController < RestController +class WorkloadController < RestController include AuthorizeResource include BodyParser include FindPolicyResource @@ -26,7 +26,7 @@ def post if !hostResource.nil? raise Exceptions::RecordExists.new("host", hostId) end - input = input_host_create(params) + input = input_workload_create(params) result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input, resource(params[:identifier])) hostPolicy = result[:policy] grantPolicies = grantHostToSafes(params) @@ -52,7 +52,7 @@ def validateId(id) end end -def input_host_create(json_body) +def input_workload_create(json_body) { "id" => json_body[:id], "annotations" => json_body[:annotations], diff --git a/spec/controllers/hosts_controller_spec.rb b/spec/controllers/workload_controller_spec.rb similarity index 99% rename from spec/controllers/hosts_controller_spec.rb rename to spec/controllers/workload_controller_spec.rb index 2008e987aa..3d7b813aa5 100644 --- a/spec/controllers/hosts_controller_spec.rb +++ b/spec/controllers/workload_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' DatabaseCleaner.strategy = :truncation -describe HostsController, type: :request do +describe WorkloadController, type: :request do let(:test_value) { "testvalue" } let(:url_variable) { "/secrets/rspec/variable" } before do From d182e758a4a2a0ca855d11315eb75a8d217ece62 Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Mon, 4 Sep 2023 15:36:46 +0300 Subject: [PATCH 108/665] Code review --- app/models/loader/orchestrate.rb | 4 ++-- app/models/loader/types.rb | 14 +++++++------- spec/models/loader/types.rb | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index 83383b864e..dd9f15c304 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -422,7 +422,7 @@ def perform_deletion # rubocop:enable Style/GuardClause end - $basic_schema = "" + $primary_schema = "" # Loads the records into the temporary schema (since the schema search path # contains only the temporary schema). @@ -451,7 +451,7 @@ def in_primary_schema &block # Creates a set of tables in the new schema to mirror the tables in the primary schema. # The new tables are not created with constraints, aside from primary keys. def create_schema - $basic_schema = db.search_path + $primary_schema = db.search_path db.execute("CREATE SCHEMA #{schema_name}") db.search_path = schema_name diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index 5f718fde3a..014cd27501 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -111,9 +111,9 @@ class Record < Types::Base @current_schema = "" - def lookup_committed - @current_schema = Issuer.db.search_path - Sequel::Model.db.search_path = $basic_schema + def lookup_primary + @current_schema = Sequel::Model.db.search_path + Sequel::Model.db.search_path = $primary_schema end def lookup_current @@ -133,7 +133,7 @@ def auth_resource privilege, resource_id def calculate_defaults!; end def create! - lookup_committed + lookup_primary verify lookup_current calculate_defaults! @@ -306,11 +306,11 @@ class Variable < Record def verify; if self.id.start_with?("data/ephemerals") - if self.annotations["ephemerals/issuer"].nil? + if self.annotations["ephemeral/issuer"].nil? message = "Ephemeral variable #{self.id} has no issuer annotation" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) else - issuer_id = self.annotations["ephemerals/issuer"] + issuer_id = self.annotations["ephemeral/issuer"] issuer = Issuer.where(account: @policy_object.account, issuer_id: issuer_id).first if (issuer.nil?) @@ -322,7 +322,7 @@ def verify; auth_resource(:use, resource_id) end else - if !(self.annotations.nil?) && !(self.annotations["ephemerals/issuer"].nil?) + if !(self.annotations.nil?) && !(self.annotations["ephemeral/issuer"].nil?) message = "Regular variable #{self.id} issuer is defined" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 8186cda2e3..fdad976d80 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -154,7 +154,7 @@ variable.id = resource_id variable.account = "conjur" if issuer_id != '' - variable.annotations = { "ephemerals/issuer" => issuer_id } + variable.annotations = { "ephemeral/issuer" => issuer_id } end Loader::Types.wrap(variable, self) end @@ -162,7 +162,7 @@ describe '.verify' do context 'when no issuer configured' do before do - $basic_schema = "public" + $primary_schema = "public" end context 'when creating regular variable without ephemerals/issuer annotation' do @@ -193,7 +193,7 @@ context 'when issuer aws1 configured without permissions' do before do allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}).and_return(issuer_object) - $basic_schema = "public" + $primary_schema = "public" end context 'when creating regular variable with ephemerals/issuer annotation' do @@ -223,7 +223,7 @@ allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}) .and_return(issuer_object) allow_any_instance_of(AuthorizeResource).to receive(:authorize).with(:use, nil) - $basic_schema = "public" + $primary_schema = "public" end context 'when creating regular variable with ephemerals/issuer annotation' do From 70e669b7e6dbf26490ed98604afb09e10967a5ce Mon Sep 17 00:00:00 2001 From: nofarvered Date: Mon, 4 Sep 2023 15:55:50 +0300 Subject: [PATCH 109/665] promote 107 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4ea631e8..d1a4628679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.6-cloud] - 2023-09-04 +## [1.0.7-cloud] - 2023-09-04 ### Changed - Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret [ONYX-42993](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42993) From 06d0b6a5f5b244a720cbc7142e8d353956481d8c Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Mon, 4 Sep 2023 16:06:04 +0300 Subject: [PATCH 110/665] Fixing tests --- spec/controllers/issuers_controller_spec.rb | 44 +++++++++++++++------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/spec/controllers/issuers_controller_spec.rb b/spec/controllers/issuers_controller_spec.rb index 62f48352fa..57d1d4a81a 100644 --- a/spec/controllers/issuers_controller_spec.rb +++ b/spec/controllers/issuers_controller_spec.rb @@ -141,6 +141,16 @@ POLICY end it 'it returns conflict' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :created + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer/delegation")).not_to eq(nil) + expect(Resource.find(resource_id: "rspec:group:conjur/issuers/my-new-aws-issuer/delegation/consumers")).not_to eq(nil) + post( '/policies/rspec/policy/root', env: token_auth_header(role: admin_user).merge( @@ -149,15 +159,7 @@ ) assert_response :success expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/related-ephemeral-variable")).to_not eq(nil) - post("/issuers/rspec", - env: token_auth_header(role: admin_user).merge( - 'RAW_POST_DATA' => payload_create_issuer_input, - 'CONTENT_TYPE' => "application/json" - )) - assert_response :internal_server_error - expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer")).to eq(nil) - expect(Resource.find(resource_id: "rspec:policy:conjur/issuers/my-new-aws-issuer/delegation")).to eq(nil) - expect(Resource.find(resource_id: "rspec:group:conjur/issuers/my-new-aws-issuer/delegation/consumers")).to eq(nil) + end end @@ -337,6 +339,19 @@ } BODY end + let(:payload_create_other_issuer) do + <<~BODY + { + "id": "my-other-issuer", + "max_ttl": 200, + "type": "aws", + "data": { + "access_key_id": "aaa", + "secret_access_key": "a" + } + } + BODY + end let(:payload_create_ephemeral_variables) do <<~POLICY - !policy @@ -349,7 +364,7 @@ - !variable id: unrelated-ephemeral-variable annotations: - ephemeral/issuer: my-other-issuer + ephemeral/issuer: my-other-issuer POLICY end let(:payload_create_non_ephemeral_variable) do @@ -369,6 +384,11 @@ 'RAW_POST_DATA' => payload_create_issuer, 'CONTENT_TYPE' => "application/json" )) + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_other_issuer, + 'CONTENT_TYPE' => "application/json" + )) assert_response :created post( '/policies/rspec/policy/root', @@ -385,7 +405,7 @@ 'RAW_POST_DATA' => payload_create_non_ephemeral_variable ) ) - assert_response :success + assert_response :unprocessable_entity delete("/issuers/rspec/my-new-aws-issuer", env: token_auth_header(role: admin_user)) assert_response :success @@ -397,7 +417,7 @@ # Non related ephemeral variables and non ephemeral variables are not deleted expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/unrelated-ephemeral-variable")).to_not eq(nil) - expect(Resource.find(resource_id: "rspec:variable:data/non-ephemeral-variable")).to_not eq(nil) + expect(Resource.find(resource_id: "rspec:variable:data/non-ephemeral-variable")).to eq(nil) end context "when a user deletes a non existing issuer without permissions" do From 38ac3a0e905b9092f6970923bf31a1df7e5fdc7a Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Mon, 4 Sep 2023 16:38:17 +0300 Subject: [PATCH 111/665] Code review --- app/models/loader/types.rb | 10 +++++----- spec/models/loader/types.rb | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index 014cd27501..da985ea728 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -305,12 +305,12 @@ class Variable < Record def_delegators :@policy_object, :kind, :mime_type def verify; - if self.id.start_with?("data/ephemerals") - if self.annotations["ephemeral/issuer"].nil? + if self.id.start_with?(Issuer::EPHEMERAL_VARIABLE_PREFIX) + if self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"].nil? message = "Ephemeral variable #{self.id} has no issuer annotation" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) else - issuer_id = self.annotations["ephemeral/issuer"] + issuer_id = self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"] issuer = Issuer.where(account: @policy_object.account, issuer_id: issuer_id).first if (issuer.nil?) @@ -322,8 +322,8 @@ def verify; auth_resource(:use, resource_id) end else - if !(self.annotations.nil?) && !(self.annotations["ephemeral/issuer"].nil?) - message = "Regular variable #{self.id} issuer is defined" + if !(self.annotations.nil?) && !(self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"].nil?) + message = "Ephemeral variable #{self.id} not in right path" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end end diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index fdad976d80..48da0fd63f 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -226,21 +226,21 @@ $primary_schema = "public" end - context 'when creating regular variable with ephemerals/issuer annotation' do + context 'when creating regular variable with ephemerals/issuer aws1' do let(:resource_id) { 'data/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { nil } it { expect { variable.verify }.to raise_error } end - context 'when creating ephemeral variable with ephemerals/issuer annotation' do + context 'when creating ephemeral variable with ephemerals/issuer aws1' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { nil } it { expect { variable.verify }.to raise_error } end - context 'when creating ephemeral variable with ephemerals/issuer annotation' do + context 'when creating ephemeral variable with ephemerals/issuer aws1 and with permissions' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { 'issuer' } From 0f3687931872e1cb7a46849f44bcc59b12f627d1 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Mon, 4 Sep 2023 16:45:12 +0300 Subject: [PATCH 112/665] ONYX-43857: Refactor host controller name to workload controller name --- config/routes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 271ea150db..8f44024986 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,7 +67,7 @@ def matches?(request) get "/resources/:account" => "resources#index" get "/resources" => "resources#index" - post "hosts/:account/*identifier" => "hosts#post" + post "hosts/:account/*identifier" => "workload#post" get "/:authenticator/:account/providers" => "providers#index" From 50a7e202150645d7e6f8508f54b3b8f8e013c88a Mon Sep 17 00:00:00 2001 From: Ofira Burstein Date: Tue, 5 Sep 2023 10:19:02 +0300 Subject: [PATCH 113/665] Fixing tests --- app/models/loader/orchestrate.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index dd9f15c304..fce6f491e5 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -422,7 +422,7 @@ def perform_deletion # rubocop:enable Style/GuardClause end - $primary_schema = "" + $primary_schema = "public" # Loads the records into the temporary schema (since the schema search path # contains only the temporary schema). From 9feb7e096e209e87a9d28ce937079f2fc5471357 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Mon, 4 Sep 2023 17:12:08 +0300 Subject: [PATCH 114/665] ONYX-42600 Add to get all authenticators endpoint limit and offset params --- CHANGELOG.md | 5 +- app/controllers/concerns/edge_validator.rb | 5 + .../edge_authenticators_controller.rb | 13 +- .../edge/internal/edge_hosts_controller.rb | 5 - .../edge/internal/edge_secrets_controller.rb | 4 - .../authenticators/authenticators_manager.rb | 29 ++-- app/models/authenticator.rb | 6 +- .../internal/edge_jwt_replication.feature | 140 +++++++++++++++++- 8 files changed, 172 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a4628679..64c799123a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret [ONYX-42993](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42993) -- Get all authenticators endpoint, will be used by edge for replication. -- Modify edge logs. +- Get all authenticators endpoint, will be used by edge for replication +- Modify edge logs +- Add limit and offset to get all authenticators endpoint [ONYX-44074](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-44074) ## [1.0.6-cloud] - 2023-08-21 ### Changed diff --git a/app/controllers/concerns/edge_validator.rb b/app/controllers/concerns/edge_validator.rb index feb5994ef9..52c67cc141 100644 --- a/app/controllers/concerns/edge_validator.rb +++ b/app/controllers/concerns/edge_validator.rb @@ -46,4 +46,9 @@ def validate_scope(limit, offset) end end + private + def numeric? val + val == val.to_i.to_s + end + end diff --git a/app/controllers/edge/internal/edge_authenticators_controller.rb b/app/controllers/edge/internal/edge_authenticators_controller.rb index 1d8b8d8293..9cc553b65f 100644 --- a/app/controllers/edge/internal/edge_authenticators_controller.rb +++ b/app/controllers/edge/internal/edge_authenticators_controller.rb @@ -7,8 +7,6 @@ class EdgeAuthenticatorsController < RestController include BodyParser include Cryptography include EdgeValidator - include ExtractEdgeResources - include GroupMembershipValidator include AuthenticatorsManager def all_authenticators logger.info(LogMessages::Endpoints::EndpointRequested.new("all-authenticators")) @@ -19,21 +17,24 @@ def all_authenticators verify_edge_host(options) verify_kind(options[:kind]) kinds = options[:kind].split(',') - scope = get_authenticators_data(kinds) if params[:count] == 'true' - count=params[:count] + scope = get_authenticators_data(kinds) count_authenticators={} scope.each do |authenticator_kind, authenticators_list| count_authenticators[authenticator_kind] = authenticators_list.size end results = { count: count_authenticators } - logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithCount.new("all-authenticators", count)) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithCount.new("all-authenticators", params[:count])) render(json: results) else + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) verify_header(request) response.set_header("Content-Encoding", "base64") + parsed_data = get_authenticators_parsed_data(kinds, offset, limit) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all-authenticators")) - render(json: scope) + render(json: parsed_data) end rescue ApplicationController::Forbidden raise diff --git a/app/controllers/edge/internal/edge_hosts_controller.rb b/app/controllers/edge/internal/edge_hosts_controller.rb index 4b9176a7a1..0c1b467609 100644 --- a/app/controllers/edge/internal/edge_hosts_controller.rb +++ b/app/controllers/edge/internal/edge_hosts_controller.rb @@ -58,9 +58,4 @@ def all_hosts end end - private - - def numeric? val - val == val.to_i.to_s - end end \ No newline at end of file diff --git a/app/controllers/edge/internal/edge_secrets_controller.rb b/app/controllers/edge/internal/edge_secrets_controller.rb index 5ee83eea7d..38eeafd72f 100644 --- a/app/controllers/edge/internal/edge_secrets_controller.rb +++ b/app/controllers/edge/internal/edge_secrets_controller.rb @@ -110,8 +110,4 @@ def build_variables_map(limit, offset, options) variables end - def numeric? val - val == val.to_i.to_s - end - end \ No newline at end of file diff --git a/app/domain/edge_logic/authenticators/authenticators_manager.rb b/app/domain/edge_logic/authenticators/authenticators_manager.rb index afe5a5e98d..6f30e6eabe 100644 --- a/app/domain/edge_logic/authenticators/authenticators_manager.rb +++ b/app/domain/edge_logic/authenticators/authenticators_manager.rb @@ -1,28 +1,36 @@ # frozen_string_literal: true module AuthenticatorsManager + def get_authenticators_data(kinds) return_json = {} kinds.each do |kind| if kind == "authn-jwt" - return_json[kind] = authn_jwt_handler + return_json[kind] = Authenticator.jwt.all + end + end + return_json + end + + def get_authenticators_parsed_data(kinds, offset, limit) + return_json = {} + kinds.each do |kind| + if kind == "authn-jwt" + return_json[kind] = authn_jwt_handler(offset, limit) end end return_json end - def authn_jwt_handler + def authn_jwt_handler(offset,limit) results = [] unique_properties = %w[jwksUri publicKeys caCert tokenAppProperty identityPath issuer enforcedClaims claimAliases audience] unique_properties_name_in_policy = %w[jwks-uri public-keys ca-cert token-app-property identity-path issuer enforced-claims claim-aliases audience] property_map = unique_properties_name_in_policy.zip(unique_properties).to_h begin - authenticators = Authenticator.jwt + authenticators = Authenticator.jwt.limit((limit || 1000).to_i, (offset || 0).to_i) authenticators.each do |authenticator| authenticatorToReturn = {} authenticatorToReturn[:id] = authenticator[:resource_id] - unless validate_authenticator_path(authenticator[:resource_id]) - next - end # if the the enable is not configured the default value will be false authenticatorToReturn[:enabled] = authenticator[:enabled] # if there is no permissions for authenticator the default value will be nil @@ -66,14 +74,7 @@ def authn_jwt_handler end private - def validate_authenticator_path(resource_id) - # Valid authenticator is only two levels under conjur policy in the policy tree - # otherwise it is not a valid authenticator. - # valid : conjur/authn-jwt/myVendor - # not valid : conjur/authn-jwt/myVendor/status - resource_id.count("/") == 2 - end - + def build_enforced_claims(value) # enforced-claims should be a array of strings seperated by comma if value == "" diff --git a/app/models/authenticator.rb b/app/models/authenticator.rb index c4b19eaec8..162bc3faaf 100644 --- a/app/models/authenticator.rb +++ b/app/models/authenticator.rb @@ -12,7 +12,7 @@ def get_property(id) end end def jwt - authn_jwt_prefix = "webservice:conjur/authn-jwt/" + authn_jwt_regex = "webservice:conjur/authn-jwt/[^/]+$" # valid : conjur/authn-jwt/myVendor, not valid : conjur/authn-jwt/myVendor/status variable_jwt_prefix = "variable:conjur/authn-jwt/" resources_with_authenticator_configs = "SELECT resource_id, enabled FROM authenticator_configs" @@ -30,7 +30,7 @@ def jwt LEFT JOIN permissions ON resources.resource_id = permissions.resource_id LEFT JOIN (#{resources_with_authenticator_configs}) AS authn_configs ON resources.resource_id = authn_configs.resource_id - WHERE resources.resource_id LIKE '%#{authn_jwt_prefix}%' + WHERE resources.resource_id ~ '#{authn_jwt_regex}' GROUP BY resources.resource_id, authn_configs.enabled" # extract all properties (secrets) that belongs to some authn-jwt @@ -70,7 +70,7 @@ def jwt FROM Shared LEFT JOIN UniqueProps ON split_part(Shared.resource_id, '/', 3) = split_part(UniqueProps.property_id, '/', 3) GROUP BY Shared.resource_id, Shared.permissions, Shared.enabled - ORDER BY Shared.resource_id; + ORDER BY Shared.resource_id }) scope end diff --git a/cucumber/api/features/edge/internal/edge_jwt_replication.feature b/cucumber/api/features/edge/internal/edge_jwt_replication.feature index cb6119dde3..95d4b05063 100644 --- a/cucumber/api/features/edge/internal/edge_jwt_replication.feature +++ b/cucumber/api/features/edge/internal/edge_jwt_replication.feature @@ -56,6 +56,12 @@ Feature: Replicate jwt authenticators from edge endpoint - !webservice - !variable jwks-uri - !variable ca-cert + - !policy + id: conjur/authn-jwt/bestAuthenticator + body: + - !webservice + - !variable jwks-uri + - !variable ca-cert """ And I add the secret value "https://www.googleapis.com/oauth2/v3/certs" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/jwks-uri" And I add the secret value "app_name" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/token-app-property" @@ -78,6 +84,88 @@ Feature: Replicate jwt authenticators from edge endpoint Then the HTTP response status code is 200 And the JSON should be: """ + { + "authn-jwt": [ + { + "audience": null, + "caCert": "", + "claimAliases": null, + "enabled": false, + "enforcedClaims": null, + "id": "cucumber:webservice:conjur/authn-jwt/bestAuthenticator", + "identityPath": null, + "issuer": null, + "jwksUri": "", + "permissions": null, + "publicKeys": null, + "tokenAppProperty": null + }, + { + "id": "cucumber:webservice:conjur/authn-jwt/myVendor", + "enabled": true, + "permissions": [ + { + "privilege": "authenticate", + "role": "cucumber:group:conjur/authn-jwt/myVendor/apps" + }, + { + "privilege": "read", + "role": "cucumber:group:conjur/authn-jwt/myVendor/apps" + } + ], + "jwksUri": "aHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vb2F1dGgyL3YzL2NlcnRz", + "publicKeys": null, + "caCert": "", + "tokenAppProperty": "YXBwX25hbWU=", + "identityPath": "ZGF0YS9teXNwYWNlL2p3dC1hcHBz", + "issuer": "aHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbQ==", + "enforcedClaims": [ + "Z29vZ2xlL2NsYWlt", + "IGF6dXJlL2NsYWlt" + ], + "claimAliases": [ + { + "annotationName": "Y2xhaW0=", + "claimName": "Z29vZ2xlL2NsYWlt" + }, + { + "annotationName": "IG15Y2xhaW0=", + "claimName": "YXp1cmUvY2xhaW0=" + } + ], + "audience": "" + }, + { + "id": "cucumber:webservice:conjur/authn-jwt/withoutPermissions", + "enabled": false, + "permissions": null, + "jwksUri": "", + "publicKeys": null, + "caCert": "", + "tokenAppProperty": null, + "identityPath": null, + "issuer": null, + "enforcedClaims": null, + "claimAliases": null, + "audience": null + } + ] + } + """ + + @acceptance + Scenario: Fetching hosts with parameters + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + limit: 2 + offset: 1 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ { "authn-jwt": [ { @@ -131,7 +219,57 @@ Feature: Replicate jwt authenticators from edge endpoint } ] } + """ + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + limit: 10 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "authn-jwt" should have 3 entries + + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "authn-jwt" should have 3 entries + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "authn-jwt" should have 1 entries + When I GET "/edge/authenticators/cucumber" with parameters: """ + kind: authn-jwt + limit: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "authn-jwt" should have 2 entries + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + limit: 5 + """ + Then the HTTP response status code is 200 + And the JSON at "authn-jwt" should have 3 entries + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + limit: 0 + """ + Then the HTTP response status code is 422 + When I GET "/edge/authenticators/cucumber" with parameters: + """ + kind: authn-jwt + limit: 2001 + """ + Then the HTTP response status code is 422 + @negative Scenario: Fetching authenticators with non edge host return 403 error @@ -165,7 +303,7 @@ Feature: Replicate jwt authenticators from edge endpoint """ { "count": { - "authn-jwt": 2 + "authn-jwt": 3 } } """ From 2d0c36b58a8041a31d214186b7274be36d5f1573 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Thu, 7 Sep 2023 15:22:44 +0300 Subject: [PATCH 115/665] ONYX-44560: Read tenant details from env variables --- CHANGELOG.md | 4 +++ .../data_handlers/ongoing_handler.rb | 12 ++----- lib/conjur/conjur_config.rb | 36 ++++++++++++++++--- .../conjur_ephemeral_engine_client_spec.rb | 13 ++----- .../internal/edge_handler_controller_spec.rb | 21 +---------- spec/lib/conjur/conjur_config_spec.rb | 30 ++++++++++++++++ 6 files changed, 72 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64c799123a..1a41b5aa55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.8-cloud] - 2023-10-01 +### Changed +- New env variables + ## [1.0.7-cloud] - 2023-09-04 ### Changed - Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret diff --git a/app/domain/edge_logic/data_handlers/ongoing_handler.rb b/app/domain/edge_logic/data_handlers/ongoing_handler.rb index 70ba8cc678..d61a71bdb4 100644 --- a/app/domain/edge_logic/data_handlers/ongoing_handler.rb +++ b/app/domain/edge_logic/data_handlers/ongoing_handler.rb @@ -25,7 +25,7 @@ def call(params, hostname, ip) #convert time to seconds last_synch_time_sec = Rational(stats['last_synch_time'], 1000) installation_time_sec = Rational(edge.installation_date, 1000) - @logger.info(LogMessages::Edge::EdgeTelemetry.new(parsed_tenant_id, edge.name, Time.at(last_synch_time_sec), + @logger.info(LogMessages::Edge::EdgeTelemetry.new(tenant_id, edge.name, Time.at(last_synch_time_sec), cycle_reqs['get_secret'], cycle_reqs['apikey_authenticate'], cycle_reqs['jwt_authenticate'], cycle_reqs['redirect'], edge.version, edge.platform, Time.at(installation_time_sec))) @@ -48,14 +48,8 @@ def input_validator private - def parsed_tenant_id - tenant_id = Rails.application.config.conjur_config.tenant_id - if tenant_id.match?(/\A[a-f0-9]{32}\z/) - # Insert dashes at the specific positions to achieve the tenant-id format - formatted_output = "#{tenant_id[0..7]}-#{tenant_id[8..11]}-#{tenant_id[12..15]}-#{tenant_id[16..19]}-#{tenant_id[20..31]}" - else - "" - end + def tenant_id + Rails.application.config.conjur_config.tenant_id end end diff --git a/lib/conjur/conjur_config.rb b/lib/conjur/conjur_config.rb index 148ce8eb32..40f0f0c382 100644 --- a/lib/conjur/conjur_config.rb +++ b/lib/conjur/conjur_config.rb @@ -40,7 +40,10 @@ class ConjurConfig < Anyway::Config authenticators: [], extensions: [], slosilo_rotation_interval: 24, # Sloislo rotation should be every 24 hours - tenant_id: @tenant_id + tenant_id: @tenant_id, + tenant_name: @tenant_name, + tenant_env: @tenant_env, + tenant_region: @tenant_region ) def initialize( @@ -52,6 +55,9 @@ def initialize( # logger before verifying permissions. @logger = logger @tenant_id = tenant_id + @tenant_name = tenant_name + @tenant_env = tenant_env + @tenant_region = tenant_region # First verify that we have the permissions necessary to read the config # file. verify_config_is_readable @@ -123,10 +129,32 @@ def extensions=(val) end def tenant_id - #parsing tenant_id from hostname begin - result = ENV["HOSTNAME"] - result.split("-")[1] || "" + ENV["TENANT_ID"] + rescue + "" + end + end + + def tenant_name + begin + ENV["TENANT_NAME"] + rescue + "" + end + end + + def tenant_env + begin + ENV["TENANT_ENV"] + rescue + "" + end + end + + def tenant_region + begin + ENV["TENANT_REGION"] rescue "" end diff --git a/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb b/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb index 4f45811690..40e693af91 100644 --- a/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb +++ b/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb @@ -257,25 +257,16 @@ def mock_ephemeral_secrets_service(response_code) context "when the hostname is parsed for the tenant ID" do it "then the tenant ID is successfully found" do - ENV["HOSTNAME"] = "cnj-my_tenant_id-value1-value2" + ENV["TENANT_ID"] = "my_tenant_id" result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)).tenant_id expect(result).to eq("my_tenant_id") end end - context "when the hostname has an unexpected value" do - it "then the tenant ID is empty" do - ENV["HOSTNAME"] = "some_unexpected_value" - result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)).tenant_id - - expect(result).to eq("") - end - end - context "when the hostname does not exist" do it "then the tenant ID is empty" do - ENV["HOSTNAME"] = "" + ENV["TENANT_ID"] = "" result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(error: nil)).tenant_id expect(result).to eq("") diff --git a/spec/controllers/edge/internal/edge_handler_controller_spec.rb b/spec/controllers/edge/internal/edge_handler_controller_spec.rb index 6946f1497a..854c3058ac 100644 --- a/spec/controllers/edge/internal/edge_handler_controller_spec.rb +++ b/spec/controllers/edge/internal/edge_handler_controller_spec.rb @@ -45,7 +45,7 @@ edge_details = '{"edge_statistics": {"last_synch_time": 1692633684386, "cycle_requests": { "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, "edge_version": "1.1.1", "edge_container_type": "podman"}' - ENV["HOSTNAME"] = " cnj-44da78944cc54bcdb37c316ad40ec8c6-85b9f7d95b-fwfm5" + ENV["TENANT_ID"] = "44da7894-4cc5-4bcd-b37c-316ad40ec8c6" post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) .merge({'RAW_POST_DATA': edge_details}) .merge({'CONTENT_TYPE': 'application/json'})) @@ -60,25 +60,6 @@ %w[edgy 123 234 345 456 44da7894-4cc5-4bcd-b37c-316ad40ec8c6 2023-08-21].each {|arg| expect(output).to include(arg)} end - it "Report ongoing data endpoint works with wrong tenant format" do - edge_details = '{"edge_statistics": {"last_synch_time": 222222223, "cycle_requests": { - "get_secret":123,"apikey_authenticate": 234, "jwt_authenticate":345, "redirect": 456}}, - "edge_version": "1.1.2", "edge_container_type": "docker"}' - ENV["HOSTNAME"] = " cnj-44da78944cc54bcdb37c316ad40ec8c-85b9f7d95b-fwfm5" - post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) - .merge({'RAW_POST_DATA': edge_details}) - .merge({'CONTENT_TYPE': 'application/json'})) - - expect(response.code).to eq("204") - db_edgy = Edge.where(name: "edgy").first - expect(db_edgy.last_sync.to_i).to eq(222222223) - expect(db_edgy.version).to eq("1.1.2") - expect(db_edgy.platform).to eq("docker") - output = log_output.string - expect(output).to include("EdgeTelemetry") - %w[edgy 123 234 345 456 1970-01-03].each {|arg| expect(output).to include(arg)} - end - it "Report invalid data" do missing_optional = '{"edge_statistics": {"last_synch_time": 222222222}, "edge_version": "1.1.1"}' post("#{report_edge}?data_type=ongoing", env: token_auth_header(role: @current_user, is_user: false) diff --git a/spec/lib/conjur/conjur_config_spec.rb b/spec/lib/conjur/conjur_config_spec.rb index 1621b30b7c..88b05c6431 100644 --- a/spec/lib/conjur/conjur_config_spec.rb +++ b/spec/lib/conjur/conjur_config_spec.rb @@ -316,6 +316,36 @@ ) end end + + context "with tenant env var" do + before do + ENV['TENANT_ID'] = "44da7894-4cc5-4bcd-b37c-316ad40ec8c6" + ENV['TENANT_NAME'] = "tenant1" + ENV['TENANT_ENV'] = "test" + ENV['TENANT_REGION'] = "us-east-1" + + # Anyway Config caches prefixed env vars at the class level so we must + # clear the cache to have it pick up the new var with a reload. + Anyway.env.clear + end + + after do + ENV.delete('TENANT_ID') + ENV.delete('TENANT_NAME') + ENV.delete('TENANT_ENV') + ENV.delete('TENANT_REGION') + + # Clear again to make sure we don't affect future tests. + Anyway.env.clear + end + + it "overrides the config file value" do + expect(subject.tenant_id).to eq("44da7894-4cc5-4bcd-b37c-316ad40ec8c6") + expect(subject.tenant_name).to eq("tenant1") + expect(subject.tenant_env).to eq("test") + expect(subject.tenant_region).to eq("us-east-1") + end + end end end From b4bc579b044e0f7210487d7792f33088ce6ce006 Mon Sep 17 00:00:00 2001 From: ygeva Date: Sun, 10 Sep 2023 17:04:56 +0300 Subject: [PATCH 116/665] fix tests --- app/controllers/secrets_controller.rb | 2 +- .../conjur_ephemeral_engine_client.rb | 21 +++++++++++-------- app/domain/logs.rb | 19 +++++++++++++++-- dev/docker-compose.yml | 5 +++++ .../conjur_ephemeral_engine_client_spec.rb | 6 +++--- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/app/controllers/secrets_controller.rb b/app/controllers/secrets_controller.rb index 7d548a6060..ee60745b75 100644 --- a/app/controllers/secrets_controller.rb +++ b/app/controllers/secrets_controller.rb @@ -184,7 +184,7 @@ def handle_ephemeral_secret # There shouldn't be a state where a variable belongs to an issuer that doesn't exit, but we check it to be safe raise ApplicationController::InternalServerError, "Issuer assigned to #{account}:#{params[:kind]}:#{params[:identifier]} was not found" unless issuer - logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(variable_data["issuer"], issuer.issuer_type, variable_data["method"], request_id)) + logger.info(LogMessages::Secrets::EphemeralSecretRequest.new(request_id, variable_data["issuer"], issuer.issuer_type, variable_data["method"])) issuer_data = { max_ttl: issuer.max_ttl, diff --git a/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb b/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb index 606bb7d476..db0806d432 100644 --- a/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb +++ b/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client.rb @@ -9,11 +9,14 @@ class ConjurEphemeralEngineClient include EphemeralEngineClient + @@secrets_service_address = ENV['EPHEMERAL_SECRETS_SERVICE_ADDRESS'] || "ephemeral-secrets" + @@secrets_service_port = ENV['EPHEMERAL_SECRETS_SERVICE_PORT'] || "8080" + def initialize(logger:, request_id:, http_client: nil) if http_client @client = http_client else - @client = Net::HTTP.new("http://127.0.0.1") + @client = Net::HTTP.new(@@secrets_service_address, @@secrets_service_port.to_i) @client.use_ssl = false # Service mesh takes care of the TLS communication end @logger = logger @@ -31,7 +34,7 @@ def get_ephemeral_secret(type, method, role_id, issuer_data, variable_data) # Create the POST request secret_request = Net::HTTP::Post.new("/secrets") - secret_request.body = request_body.as_json + secret_request.body = request_body.to_json # Add headers secret_request.add_field('Content-Type', 'application/json') @@ -39,22 +42,22 @@ def get_ephemeral_secret(type, method, role_id, issuer_data, variable_data) secret_request.add_field('X-Tenant-ID', tenant_id) # Send the request and get the response - @logger.info(LogMessages::Secrets::EphemeralSecretRemoteRequest.new(@request_id)) + @logger.debug(LogMessages::Secrets::EphemeralSecretRequestBody.new(@request_id, secret_request.body)) begin response = @client.request(secret_request) rescue => e + @logger.error(LogMessages::Secrets::EphemeralSecretRemoteRequestFailure.new(@request_id, e.message)) raise ApplicationController::InternalServerError, e.message end - @logger.info(LogMessages::Secrets::EphemeralSecretRemoteResponse.new(@request_id, response.code)) - response_body = JSON.parse(response.body) + @logger.debug(LogMessages::Secrets::EphemeralSecretRemoteResponse.new(@request_id, response.code)) case response.code.to_i when 200..299 - return JSON.parse(response.body) - when 400..499 - raise ApplicationController::BadRequest, "Failed to create the ephemeral secret. Code: #{response_body['code']}, Message: #{response_body['message']}, description: #{response_body['description']}" + return response.body else - raise ApplicationController::InternalServerError, "Failed to create the ephemeral secret. Code: #{response_body['code']}, Message: #{response_body['message']}, description: #{response_body['description']}" + response_body = JSON.parse(response.body) + @logger.error(LogMessages::Secrets::EphemeralSecretRemoteResponseFailure.new(@request_id, response_body['code'], response_body['message'], response_body['description'])) + raise ApplicationController::UnprocessableEntity, "Failed to create the ephemeral secret. Code: #{response_body['code']}, Message: #{response_body['message']}, description: #{response_body['description']}" end end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 1d42e9204b..a53bc2a474 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -810,7 +810,7 @@ module Issuers module Secrets EphemeralSecretRequest = ::Util::TrackableLogMessageClass.new( - msg: "Received an ephemeral secret request. Issuer ID [{0}], issuer type [{1}], ephemeral method [{2}], Request ID [{3}]", + msg: "Received an ephemeral secret request. Request ID [{3}], Issuer ID [{0}], issuer type [{1}], ephemeral method [{2}]", code: "CONJ00160I" ) @@ -821,7 +821,22 @@ module Secrets EphemeralSecretRemoteResponse = ::Util::TrackableLogMessageClass.new( msg: "Received the response from the ephemeral secrets service. Request ID [{0}], HTTP code [{1}]", - code: "CONJ00162I" + code: "CONJ00161D" + ) + + EphemeralSecretRemoteRequestFailure = ::Util::TrackableLogMessageClass.new( + msg: "Failed to send the request to the ephemeral secrets service. Request ID [{0}], error: {1}", + code: "CONJ00162E" + ) + + EphemeralSecretRemoteResponseFailure = ::Util::TrackableLogMessageClass.new( + msg: "Failed to create the ephemeral secret. Request ID [{0}], code: {1}, message: {2}, description: {3}", + code: "CONJ00163E" + ) + + EphemeralSecretRequestBody = ::Util::TrackableLogMessageClass.new( + msg: "Ephemeral secret request ID [{0}], body: {1}", + code: "CONJ00166D" ) end diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 88ab8e3539..36efd194ea 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -183,6 +183,11 @@ services: volumes: - ../ci/jwt/:/usr/src/jwks/ + ephemeral-secrets: + image: 238637036211.dkr.ecr.us-east-1.amazonaws.com/mgmt-ephemeral-service-dev-repository-conjur:latest + ports: + - "8090:8080" + volumes: authn-local: jwks-volume: diff --git a/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb b/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb index 40e693af91..fcecd6f718 100644 --- a/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb +++ b/spec/app/domain/issuers/ephemeral_engines/conjur_ephemeral_engine_client_spec.rb @@ -90,7 +90,7 @@ def mock_ephemeral_secrets_service(response_code) result = MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service(nil)) .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) - expect(result).to eq(mock_secret_result) + expect(result).to eq(mock_secret_result.to_json) end end @@ -114,14 +114,14 @@ def mock_ephemeral_secrets_service(response_code) expect do MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service("400")) .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) - end.to raise_error(ApplicationController::BadRequest) do |error| + end.to raise_error(ApplicationController::UnprocessableEntity) do |error| expect(error.message).to eq("Failed to create the ephemeral secret. Code: Error code, Message: Error message, description: Error description") end expect do MockConjurEngineClient.new(logger: logger, request_id: "abc", http_client: mock_ephemeral_secrets_service("500")) .get_ephemeral_secret(issuer_type, issuer_method, role_id, issuer_data, variable_data) - end.to raise_error(ApplicationController::InternalServerError) do |error| + end.to raise_error(ApplicationController::UnprocessableEntity) do |error| expect(error.message).to eq("Failed to create the ephemeral secret. Code: Error code, Message: Error message, description: Error description") end end From 17148980b49847db58d78df3ebad9c8427d3362c Mon Sep 17 00:00:00 2001 From: nofarvered Date: Mon, 11 Sep 2023 16:37:38 +0300 Subject: [PATCH 117/665] kind tests + for some claims return empty array --- .../edge_authenticators_controller.rb | 5 +- .../authenticators/authenticators_manager.rb | 25 ++++-- .../internal/edge_jwt_replication.feature | 84 ++++++++++++------- 3 files changed, 74 insertions(+), 40 deletions(-) diff --git a/app/controllers/edge/internal/edge_authenticators_controller.rb b/app/controllers/edge/internal/edge_authenticators_controller.rb index 9cc553b65f..e82e52defd 100644 --- a/app/controllers/edge/internal/edge_authenticators_controller.rb +++ b/app/controllers/edge/internal/edge_authenticators_controller.rb @@ -48,8 +48,9 @@ def all_authenticators private def verify_kind(kinds_param) allowed_kind = ['authn-jwt'] - kinds = kinds_param.split(',') - unless kinds.all? { |value| allowed_kind.include?(value) } + kinds = kinds_param.to_s.split(',') + # the kind param cant be empty, and the values have to be from the allowed_kind list + unless kinds.present? && kinds.all? { |value| allowed_kind.include?(value) } raise ArgumentError , "authenticator kind parameter is not valid" end end diff --git a/app/domain/edge_logic/authenticators/authenticators_manager.rb b/app/domain/edge_logic/authenticators/authenticators_manager.rb index 6f30e6eabe..e92bcb0a06 100644 --- a/app/domain/edge_logic/authenticators/authenticators_manager.rb +++ b/app/domain/edge_logic/authenticators/authenticators_manager.rb @@ -79,28 +79,35 @@ def build_enforced_claims(value) # enforced-claims should be a array of strings seperated by comma if value == "" # if the value of enforced-claims is empty string it means it declared on the policy but never set a value. - # in such a case, by design, we need to return empty string. - value + # in such a case, by design, we need to return empty array. + return [] else - result = value.split(',') + result = split_string(value) result = result.map { |item| Base64.strict_encode64(item) } result end end + def split_string(input) + # Split the string by comma + result = input.split(',', -1).map(&:strip) + + result + end + def build_claim_aliases(value) # claim aliases should be a array of objects with "annotationName" : "claimName" structure if value == "" # if the value of claim aliases is empty string it means it declared on the policy but never set a value. - # in such a case, by design, we need to return empty string. - value + # in such a case, by design, we need to return empty array. + return [] else begin - result = value.split(',').map do |pair| - annotation, claim = pair.split(':') + result = value.split(',', -1).map do |pair| + annotation, claim = pair.split(':', 2) { - "annotationName" => Base64.strict_encode64(annotation), - "claimName" => Base64.strict_encode64(claim) + "annotationName" => Base64.strict_encode64(annotation.to_s.strip), + "claimName" => Base64.strict_encode64(claim.to_s.strip) } end rescue => e diff --git a/cucumber/api/features/edge/internal/edge_jwt_replication.feature b/cucumber/api/features/edge/internal/edge_jwt_replication.feature index 95d4b05063..4900fe0f0c 100644 --- a/cucumber/api/features/edge/internal/edge_jwt_replication.feature +++ b/cucumber/api/features/edge/internal/edge_jwt_replication.feature @@ -56,6 +56,8 @@ Feature: Replicate jwt authenticators from edge endpoint - !webservice - !variable jwks-uri - !variable ca-cert + - !variable enforced-claims + - !variable claim-aliases - !policy id: conjur/authn-jwt/bestAuthenticator body: @@ -68,8 +70,8 @@ Feature: Replicate jwt authenticators from edge endpoint And I add the secret value "data/myspace/jwt-apps" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/identity-path" And I add the secret value "https://login.example.com" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/issuer" And I add the secret value "additional_data/group_id" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/enforced-claims" - And I add the secret value "google/claim, azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/enforced-claims" - And I add the secret value "claim:google/claim, myclaim:azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" + And I add the secret value "google/claim,azure/claim," to the resource "cucumber:variable:conjur/authn-jwt/myVendor/enforced-claims" + And I add the secret value "claim:google/claim,,myclaim:,myclaim::azure/claim," to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" And I successfully PATCH "/authn-jwt/myVendor/cucumber" with body: """ enabled=true @@ -121,7 +123,8 @@ Feature: Replicate jwt authenticators from edge endpoint "issuer": "aHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbQ==", "enforcedClaims": [ "Z29vZ2xlL2NsYWlt", - "IGF6dXJlL2NsYWlt" + "YXp1cmUvY2xhaW0=", + "" ], "claimAliases": [ { @@ -129,8 +132,20 @@ Feature: Replicate jwt authenticators from edge endpoint "claimName": "Z29vZ2xlL2NsYWlt" }, { - "annotationName": "IG15Y2xhaW0=", - "claimName": "YXp1cmUvY2xhaW0=" + "annotationName": "", + "claimName": "" + }, + { + "annotationName": "bXljbGFpbQ==", + "claimName": "" + }, + { + "annotationName": "bXljbGFpbQ==", + "claimName": "OmF6dXJlL2NsYWlt" + }, + { + "annotationName": "", + "claimName": "" } ], "audience": "" @@ -145,8 +160,8 @@ Feature: Replicate jwt authenticators from edge endpoint "tokenAppProperty": null, "identityPath": null, "issuer": null, - "enforcedClaims": null, - "claimAliases": null, + "enforcedClaims": [], + "claimAliases": [], "audience": null } ] @@ -154,7 +169,7 @@ Feature: Replicate jwt authenticators from edge endpoint """ @acceptance - Scenario: Fetching hosts with parameters + Scenario: Fetching authenticators with parameters Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" And I set the "Accept-Encoding" header to "base64" When I GET "/edge/authenticators/cucumber" with parameters: @@ -189,7 +204,8 @@ Feature: Replicate jwt authenticators from edge endpoint "issuer": "aHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbQ==", "enforcedClaims": [ "Z29vZ2xlL2NsYWlt", - "IGF6dXJlL2NsYWlt" + "YXp1cmUvY2xhaW0=", + "" ], "claimAliases": [ { @@ -197,8 +213,20 @@ Feature: Replicate jwt authenticators from edge endpoint "claimName": "Z29vZ2xlL2NsYWlt" }, { - "annotationName": "IG15Y2xhaW0=", - "claimName": "YXp1cmUvY2xhaW0=" + "annotationName": "", + "claimName": "" + }, + { + "annotationName": "bXljbGFpbQ==", + "claimName": "" + }, + { + "annotationName": "bXljbGFpbQ==", + "claimName": "OmF6dXJlL2NsYWlt" + }, + { + "annotationName": "", + "claimName": "" } ], "audience": "" @@ -213,8 +241,8 @@ Feature: Replicate jwt authenticators from edge endpoint "tokenAppProperty": null, "identityPath": null, "issuer": null, - "enforcedClaims": null, - "claimAliases": null, + "enforcedClaims": [], + "claimAliases": [], "audience": null } ] @@ -295,6 +323,20 @@ Feature: Replicate jwt authenticators from edge endpoint When I GET "/edge/authenticators/cucumber?kind=authn-something" Then the HTTP response status code is 422 + @acceptance + Scenario: Fetching authenticators with empty kind and return 422 + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber?kind=" + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching authenticators with no kind and return 422 + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/authenticators/cucumber" + Then the HTTP response status code is 422 + @acceptance Scenario: Fetching authenticators count Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" @@ -308,19 +350,3 @@ Feature: Replicate jwt authenticators from edge endpoint } """ - @negative - Scenario: Fetching all authenticators with edge host and with not able to parse claim aliases by right structure and return 500 - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - And I add the secret value "google/claim, myclaim:azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" - And I set the "Accept-Encoding" header to "base64" - When I GET "/edge/authenticators/cucumber?kind=authn-jwt" - Then the HTTP response status code is 500 - - @negative - Scenario: Fetching all authenticators with edge host and with not able to parse claim aliases by right structure and return 500 - Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" - And I add the secret value "google/claim, myclaim::azure/claim" to the resource "cucumber:variable:conjur/authn-jwt/myVendor/claim-aliases" - And I set the "Accept-Encoding" header to "base64" - When I GET "/edge/authenticators/cucumber?kind=authn-jwt" - Then the HTTP response status code is 500 - From 4f52d6af807c922ca33fe6a46f87d54ccec25dcf Mon Sep 17 00:00:00 2001 From: nofarvered Date: Wed, 13 Sep 2023 15:39:34 +0300 Subject: [PATCH 118/665] comment out everything about ubi image --- Jenkinsfile | 20 ++++++++++---------- build.sh | 8 ++++---- publish-images.sh | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 2eddee6ebb..00c8ec63ef 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -165,16 +165,16 @@ pipeline { scanAndReport("conjur-cloud:${tagWithSHA()}", "NONE", true) } } - stage("Scan UBI-based Docker Image for fixable issues") { - steps { - scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "HIGH", false) - } - } - stage("Scan UBI-based Docker image for total issues") { - steps { - scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "NONE", true) - } - } +// stage("Scan UBI-based Docker Image for fixable issues") { +// steps { +// scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "HIGH", false) +// } +// } +// stage("Scan UBI-based Docker image for total issues") { +// steps { +// scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "NONE", true) +// } +// } } } diff --git a/build.sh b/build.sh index c3e97c4892..a08ad85f07 100755 --- a/build.sh +++ b/build.sh @@ -79,7 +79,7 @@ if image_doesnt_exist "conjur-test:$TAG"; then docker build --build-arg "VERSION=$TAG" -t "conjur-test:$TAG" -f Dockerfile.test . fi -if image_doesnt_exist "conjur-ubi-cloud:$TAG"; then - echo "Building image conjur-ubi-cloud:$TAG container" - docker build --build-arg "VERSION=$TAG" -t "conjur-ubi-cloud:$TAG" -f Dockerfile.ubi . -fi +#if image_doesnt_exist "conjur-ubi-cloud:$TAG"; then +# echo "Building image conjur-ubi-cloud:$TAG container" +# docker build --build-arg "VERSION=$TAG" -t "conjur-ubi-cloud:$TAG" -f Dockerfile.ubi . +#fi \ No newline at end of file diff --git a/publish-images.sh b/publish-images.sh index 3498a6abed..8019135fdf 100755 --- a/publish-images.sh +++ b/publish-images.sh @@ -58,10 +58,10 @@ if [[ "${PUBLISH_INTERNAL}" = true ]]; then # Always push SHA versioned images internally tag_and_push "${VERSION}-${LOCAL_TAG}" "${LOCAL_IMAGE}" "registry.tld/conjur-cloud" tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-test:${LOCAL_TAG}" "registry.tld/conjur-test" - tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-ubi-cloud:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" +# tag_and_push "${VERSION}-${LOCAL_TAG}" "conjur-ubi-cloud:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" # Push SHA only tagged images to our internal registry tag_and_push "${LOCAL_TAG}" "${LOCAL_IMAGE}" "registry.tld/conjur-cloud" tag_and_push "${LOCAL_TAG}" "conjur-test:${LOCAL_TAG}" "registry.tld/conjur-test" - tag_and_push "${LOCAL_TAG}" "conjur-ubi-cloud:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" +# tag_and_push "${LOCAL_TAG}" "conjur-ubi-cloud:${LOCAL_TAG}" "registry.tld/conjur-ubi-cloud" fi From 6600ec8357c38b5384fd7404cbed4bc11101ed09 Mon Sep 17 00:00:00 2001 From: ygeva Date: Mon, 18 Sep 2023 14:46:44 +0300 Subject: [PATCH 119/665] remove sensitive data from issuer list --- app/controllers/issuers_controller.rb | 8 ++++---- app/models/issuer.rb | 10 ++++++++++ spec/controllers/issuers_controller_spec.rb | 4 ---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/controllers/issuers_controller.rb b/app/controllers/issuers_controller.rb index 366538dffb..bbf24f55e0 100644 --- a/app/controllers/issuers_controller.rb +++ b/app/controllers/issuers_controller.rb @@ -135,12 +135,12 @@ def list authorize(action, resource) issuers = list_issuers_from_db(params[:account]) - result = [] + results = [] issuers.each do |item| - result.push(item.as_json) + results.push(item.as_json_for_list) end issuer_audit_success(params[:account], "*", "list") - render(json: { issuers: result }, status: :ok) + render(json: { issuers: results }, status: :ok) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}")) rescue Exceptions::RecordNotFound => e @@ -177,7 +177,7 @@ def get_issuer_from_db(account, issuer_id) end def list_issuers_from_db(account) - Issuer.where(account: account).all + Issuer.where(account: account).select(:issuer_id, :max_ttl, :issuer_type, :created_at, :modified_at).all end def issuer_audit_success(account, issuer_id, operation) diff --git a/app/models/issuer.rb b/app/models/issuer.rb index bd0578177f..7a51dd4685 100644 --- a/app/models/issuer.rb +++ b/app/models/issuer.rb @@ -25,6 +25,16 @@ def as_json } end + def as_json_for_list + { + id: self.issuer_id, + max_ttl: self.max_ttl, + type: self.issuer_type, + created_at: self.created_at, + modified_at: self.modified_at + } + end + def delete_issuer_variables # Find all the variables that belong to the account and start with the ephemrals prefix resource_ids = related_variables_query.select_map(:resource_id) diff --git a/spec/controllers/issuers_controller_spec.rb b/spec/controllers/issuers_controller_spec.rb index 57d1d4a81a..f2d764bd20 100644 --- a/spec/controllers/issuers_controller_spec.rb +++ b/spec/controllers/issuers_controller_spec.rb @@ -586,14 +586,10 @@ expect(parsed_body["issuers"][0]["id"]).to eq("issuer-1") expect(parsed_body["issuers"][0]["max_ttl"]).to eq(200) expect(parsed_body["issuers"][0]["type"]).to eq("aws") - expect(parsed_body["issuers"][0]["data"]["access_key_id"]).to eq("a") - expect(parsed_body["issuers"][0]["data"]["secret_access_key"]).to eq("a") expect(parsed_body["issuers"][1]["id"]).to eq("issuer-2") expect(parsed_body["issuers"][1]["max_ttl"]).to eq(300) expect(parsed_body["issuers"][1]["type"]).to eq("aws") - expect(parsed_body["issuers"][1]["data"]["access_key_id"]).to eq("aaa") - expect(parsed_body["issuers"][1]["data"]["secret_access_key"]).to eq("aaa") end end From bc1f5418cc49b4aa11395dcbae571046042b04b7 Mon Sep 17 00:00:00 2001 From: ygeva Date: Thu, 21 Sep 2023 12:26:41 +0300 Subject: [PATCH 120/665] support when data does not exist in body --- .../issuers/issuer_types/issuer_base_type.rb | 7 ++++++ spec/controllers/issuers_controller_spec.rb | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/domain/issuers/issuer_types/issuer_base_type.rb b/app/domain/issuers/issuer_types/issuer_base_type.rb index 685f83e1ec..cb5c5ee058 100644 --- a/app/domain/issuers/issuer_types/issuer_base_type.rb +++ b/app/domain/issuers/issuer_types/issuer_base_type.rb @@ -11,6 +11,7 @@ def validate(params) validate_id(params[:id]) validate_max_ttl(params[:max_ttl]) validate_type(params[:type]) + validate_not_nil_data(params[:data]) validate_no_added_parameters(params) end end @@ -64,6 +65,12 @@ def validate_type(type) end end +def validate_not_nil_data(data) + if data.nil? + raise ApplicationController::BadRequest, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "data") + end +end + def validate_no_added_parameters(params) if params.keys.count != IssuerBaseType::NUM_OF_EXPECTED_PARAMS raise ApplicationController::BadRequest, IssuerBaseType::INVALID_INPUT_PARAM diff --git a/spec/controllers/issuers_controller_spec.rb b/spec/controllers/issuers_controller_spec.rb index f2d764bd20..7a8f8de679 100644 --- a/spec/controllers/issuers_controller_spec.rb +++ b/spec/controllers/issuers_controller_spec.rb @@ -50,6 +50,28 @@ end end + context "when a user sends body without data but other non-valid field" do + let(:payload_create_4_fields_without_data) do + <<~BODY + { + "id": "aws-issuer-1", + "max_ttl": 3000, + "type": "aws", + "wrong": "wrong value" + } + BODY + end + it 'returns bad request' do + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_4_fields_without_data, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :bad_request + expect(response.body).to eq("{\"error\":{\"code\":\"bad_request\",\"message\":\"data is a required parameter and must be specified\"}}") + end + end + context "when user sends body with id, max_ttl, type and data" do let(:payload_create_issuers_complete_input) do <<~BODY From e2ed9070589d56c125ab0467e407163ff018524a Mon Sep 17 00:00:00 2001 From: ygeva Date: Thu, 21 Sep 2023 17:45:35 +0300 Subject: [PATCH 121/665] Fix 500 to 404 when no privilege to use --- app/models/loader/types.rb | 9 ++++++--- spec/models/loader/types.rb | 40 +++++++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index da985ea728..a67aa8bf2a 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -125,9 +125,12 @@ def verify raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end - def auth_resource privilege, resource_id + def auth_resource(privilege, resource_id, issuer_id, account) resource = ::Resource[resource_id] - authorize(privilege, resource) + unless current_user.allowed_to?(privilege, resource) + issuer_exception_id = "#{account}:issuer:#{issuer_id}" + raise Exceptions::RecordNotFound, issuer_exception_id + end end def calculate_defaults!; end @@ -319,7 +322,7 @@ def verify; end resource_id = @policy_object.account + ":policy:conjur/issuers/" + issuer_id - auth_resource(:use, resource_id) + auth_resource(:use, resource_id,issuer_id,@policy_object.account) end else if !(self.annotations.nil?) && !(self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"].nil?) diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 48da0fd63f..60e189beec 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -186,7 +186,7 @@ context 'when creating ephemeral variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } - it { expect { variable.verify }.to raise_error } + it { expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/ephemerals/myvar2 issuer aws1 is not defined" ) } end end @@ -200,21 +200,33 @@ let(:resource_id) { 'data/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { nil } - it { expect { variable.verify }.to raise_error } + it "raise not InvalidPolicyObject" do + allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}).and_return(issuer_object) + allow(issuer_object).to receive(:first).and_return(nil) + expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/myvar2 not in right path") + end end context 'when creating ephemeral variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { nil } - it { expect { variable.verify }.to raise_error } + + it "raise not InvalidPolicyObject" do + allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}).and_return(issuer_object) + allow(issuer_object).to receive(:first).and_return(nil) + expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/ephemerals/myvar2 issuer aws1 is not defined") + end end context 'when creating ephemeral variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { 'issuer' } - it { expect { variable.verify }.to raise_error } + it "raise not InvalidPolicyObject" do + allow_any_instance_of(Loader::Types::Record).to receive(:auth_resource).and_raise(Exceptions::RecordNotFound,"rspec:issuer:aws1") + expect { variable.verify }.to raise_error(Exceptions::RecordNotFound,"Issuer 'aws1' not found in account 'rspec'") + end end end @@ -229,23 +241,31 @@ context 'when creating regular variable with ephemerals/issuer aws1' do let(:resource_id) { 'data/myvar2' } let(:issuer_id) { 'aws1' } - let(:issuer_object) { nil } - it { expect { variable.verify }.to raise_error } + let(:issuer_object) { 'issuer' } + it "raise not InvalidPolicyObject" do + expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/myvar2 not in right path") + end end context 'when creating ephemeral variable with ephemerals/issuer aws1' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } - let(:issuer_object) { nil } - it { expect { variable.verify }.to raise_error } + let(:issuer_object) { 'issuer' } + it "raise not found record error" do + allow_any_instance_of(Loader::Types::Record).to receive(:auth_resource).and_raise(Exceptions::RecordNotFound,"rspec:issuer:aws1") + expect { variable.verify }.to raise_error(Exceptions::RecordNotFound,"Issuer 'aws1' not found in account 'rspec'") + end end context 'when creating ephemeral variable with ephemerals/issuer aws1 and with permissions' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } let(:issuer_object) { 'issuer' } - let(:policy_resource) { 'conjur:policy:conjur/issuers/aws1' } - it { expect { variable.verify }.not_to raise_error } + it "should not raise error" do + allow_any_instance_of(Loader::Types::Record).to receive(:auth_resource) + expect { variable.verify }.not_to raise_error + end + end end From f2040a84d1b996133aec37897fecb14b28fb86a0 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 13 Sep 2023 10:49:36 +0300 Subject: [PATCH 122/665] Feature flag endpoint for UI --- CHANGELOG.md | 4 ++ Gemfile | 1 + Gemfile.lock | 4 ++ app/controllers/feature_flag_controller.rb | 20 ++++++ app/domain/aws/app_config_data_client.rb | 63 +++++++++++++++++++ app/domain/util/time_expired_cache.rb | 23 +++++++ config/routes.rb | 2 +- .../domain/aws/app_config_data_client_spec.rb | 37 +++++++++++ .../domain/util/time_expired_cache_spec.rb | 25 ++++++++ .../feature_flag_controller_spec.rb | 57 +++++++++++++++++ 10 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 app/controllers/feature_flag_controller.rb create mode 100644 app/domain/aws/app_config_data_client.rb create mode 100644 app/domain/util/time_expired_cache.rb create mode 100644 spec/app/domain/aws/app_config_data_client_spec.rb create mode 100644 spec/app/domain/util/time_expired_cache_spec.rb create mode 100644 spec/controllers/feature_flag_controller_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a41b5aa55..67fe7d3304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.9-cloud] - 2023-10-08 +### Added +- Add feature flag endpoint + ## [1.0.8-cloud] - 2023-10-01 ### Changed - New env variables diff --git a/Gemfile b/Gemfile index ac6d8d951c..cb02e92dd5 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,7 @@ git_source(:github) { |name| "https://github.com/#{name}.git" } # part of a Rails project. The Ruby version is also locked in place by the # Docker base image so it won't be updated with fuzzy matching. +gem 'aws-sdk-appconfigdata' gem 'base58' gem 'command_class' gem 'http', '~> 4.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 77a6e239c0..5a9ed1cbcf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,9 @@ GEM attr_required (1.0.1) aws-eventstream (1.2.0) aws-partitions (1.553.0) + aws-sdk-appconfigdata (1.4.0) + aws-sdk-core (~> 3, >= 3.126.0) + aws-sigv4 (~> 1.1) aws-sdk-core (3.126.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) @@ -511,6 +514,7 @@ DEPENDENCIES activesupport (~> 6.1, >= 6.1.4.6) anyway_config aruba + aws-sdk-appconfigdata aws-sdk-iam base32-crockford base58 diff --git a/app/controllers/feature_flag_controller.rb b/app/controllers/feature_flag_controller.rb new file mode 100644 index 0000000000..e7dd11491a --- /dev/null +++ b/app/controllers/feature_flag_controller.rb @@ -0,0 +1,20 @@ + +class FeatureFlagController < RestController + + def feature_flag + filtered_features = "" + # Controllers are created per request, so we must use a class variable to cache the result + # of the app config call. We use a mutex to ensure that only one thread is updating the cache + Mutex.new.synchronize do + @@result_cache ||= Util::TimeExpiredCache.new(60) {Aws::AppConfigDataClient.new.get_latest_configuration} + filtered_features = @@result_cache.result + end + render(json: filtered_features) + end + + def purge_result + Mutex.new.synchronize do + @@result_cache = nil + end + end +end \ No newline at end of file diff --git a/app/domain/aws/app_config_data_client.rb b/app/domain/aws/app_config_data_client.rb new file mode 100644 index 0000000000..32f93b6a76 --- /dev/null +++ b/app/domain/aws/app_config_data_client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true +require 'aws-sdk-core' + +module Aws + class AppConfigDataClient + + def get_latest_configuration + app_config = pull_from_app_config + tenant_name = Rails.application.config.conjur_config.tenant_name + extract_active_features(app_config, tenant_name) + end + + private + def pull_from_app_config + response = nil + tenant_env = Rails.application.config.conjur_config.tenant_env + tenant_region = Rails.application.config.conjur_config.tenant_region + + Aws.config.update(region: tenant_region) + + appconfig_client = Aws::AppConfigData::Client.new + # It's ok to start a new session every time because at most this call will be made once per minute + response = appconfig_client.start_configuration_session({ + application_identifier: "#{tenant_env}-feature-flags-conjur", + environment_identifier: "#{tenant_env}-feature-flags-stable", + configuration_profile_identifier: "#{tenant_env}-feature-flags-conjur" + }) + + response = appconfig_client.get_latest_configuration({ + configuration_token: response.initial_configuration_token + }) + + response.configuration.read + rescue => e + Rails.logger.error("Error pulling app config data: #{e}") + raise ApplicationController::InternalServerError, "Error pulling app config data" + e.message + ensure + response.configuration.close if response&.configuration + end + + def extract_active_features(json, tenant_name) + json_data = JSON.parse(json) + return json_data.select do |key, value| + if value == 'ALWAYS_ON' + true + elsif value == 'ALWAYS_OFF' + false + elsif value['ON_ONLY_FOR_SPECIFIC_TENANTS'] + value['ON_ONLY_FOR_SPECIFIC_TENANTS'].include?(tenant_name) + elsif value['OFF_ONLY_FOR_SPECIFIC_TENANTS'] + value['OFF_ONLY_FOR_SPECIFIC_TENANTS'].exclude?(tenant_name) + else + Rails.logger.error("Invalid value for feature flag #{key}: #{value}") + false # invalid value + end + end.keys + rescue => e + Rails.logger.error("Error parsing app config data: #{e.message}") + raise ApplicationController::UnprocessableEntity, "Invalid app config data: #{json} - #{e.message}" + end + + end +end diff --git a/app/domain/util/time_expired_cache.rb b/app/domain/util/time_expired_cache.rb new file mode 100644 index 0000000000..e40f0cb2ad --- /dev/null +++ b/app/domain/util/time_expired_cache.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +module Util + #This class is used to cut back server calls by caching the results of a block for a given amount of time. + class TimeExpiredCache + def initialize( + interval= 60, + &block + ) + @cached_result = nil + @result_expires_at = Time.at(0) + @block = block + @interval = interval + end + + def result + if @cached_result.nil? || @result_expires_at < Time.now + @cached_result = @block.call + @result_expires_at = Time.now + @interval + end + @cached_result + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 8f44024986..0f32a6d808 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -90,7 +90,7 @@ def matches?(request) post "/edge/data/:account" => 'edge_handler#report_edge_data', :constraints => QueryParameterActionRecognizer.new("data_type") get "/edge/slosilo_keys/:account" => 'edge_slosilo_keys#slosilo_keys' - + get "/features" => "feature_flag#feature_flag" post "/issuers/:account" => 'issuers#create' delete "/issuers/:account/:identifier" => 'issuers#delete' get "/issuers/:account/:identifier" => 'issuers#get' diff --git a/spec/app/domain/aws/app_config_data_client_spec.rb b/spec/app/domain/aws/app_config_data_client_spec.rb new file mode 100644 index 0000000000..5f5d03b06e --- /dev/null +++ b/spec/app/domain/aws/app_config_data_client_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Aws::AppConfigDataClient do + let(:app_config_data) do + """ + { + \"FEATURE1\": \"ALWAYS_ON\", + \"FEATURE2\": \"ALWAYS_OFF\", + \"FEATURE3\": \"ALWAYS_ON\", + \"FEATURE4\": {\"ON_ONLY_FOR_SPECIFIC_TENANTS\" : [\"my_tenant\"]}, + \"FEATURE5\": {\"ON_ONLY_FOR_SPECIFIC_TENANTS\" : [\"other_tenant\"]}, + \"FEATURE6\": {\"OFF_ONLY_FOR_SPECIFIC_TENANTS\" : [\"my_tenant\"]}, + \"FEATURE7\": {\"OFF_ONLY_FOR_SPECIFIC_TENANTS\" : [\"other_tenant\"]} + } + """ + end + + subject { Aws::AppConfigDataClient.new } + + context "app config data processing" do + let(:expected_features) do + %w[FEATURE1 FEATURE3 FEATURE4 FEATURE7] + end + + it "extracts the correct features" do + active_features = subject.send(:extract_active_features, app_config_data, "my_tenant") + expect(active_features).to eq(expected_features) + end + + it "returns empty array when json is empty" do + active_features = subject.send(:extract_active_features, "{}", "my_tenant") + expect(active_features).to eq([]) + end + end + +end diff --git a/spec/app/domain/util/time_expired_cache_spec.rb b/spec/app/domain/util/time_expired_cache_spec.rb new file mode 100644 index 0000000000..56e0f565bd --- /dev/null +++ b/spec/app/domain/util/time_expired_cache_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe('Util::TimeDelayedCache') do + context "Callable is called only when needed" do + let(:callable) {double("callable")} + let(:block) { Proc.new { callable.call } } + subject { Util::TimeExpiredCache.new(0.1, &block) } + + it "Multiple calls within the interval trigger the block only once" do + allow(callable).to receive(:call).and_return('the result', 'other result') + expect(callable).to receive(:call).once + expect(subject.result).to eq('the result') + expect(subject.result).to eq('the result') + expect(subject.result).to eq('the result') + end + + it "Multiple calls beside the interval trigger the block twice" do + expect(callable).to receive(:call).twice.and_return('the result', 'other result') + expect(subject.result).to eq('the result') + sleep 0.2 + expect(subject.result).to eq('other result') + end + end +end diff --git a/spec/controllers/feature_flag_controller_spec.rb b/spec/controllers/feature_flag_controller_spec.rb new file mode 100644 index 0000000000..be304aea1b --- /dev/null +++ b/spec/controllers/feature_flag_controller_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe FeatureFlagController, type: :request do + let(:app_config_data) do + """ + { + \"FEATURE1\": \"ALWAYS_ON\", + \"FEATURE2\": \"ALWAYS_OFF\", + \"FEATURE3\": \"ALWAYS_ON\", + \"FEATURE4\": {\"ON_ONLY_FOR_SPECIFIC_TENANTS\" : [\"my_tenant\"]}, + \"FEATURE5\": {\"ON_ONLY_FOR_SPECIFIC_TENANTS\" : [\"other_tenant\"]}, + \"FEATURE6\": {\"OFF_ONLY_FOR_SPECIFIC_TENANTS\" : [\"my_tenant\"]}, + \"FEATURE7\": {\"OFF_ONLY_FOR_SPECIFIC_TENANTS\" : [\"other_tenant\"]} + } + """ + end + + subject {FeatureFlagController.new} + + describe "/features endpoint" do + let(:account) { "rspec" } + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: "#{account}:user:alice") + end + + context "endpoint returns expected responses" do + before(:each) do + allow_any_instance_of(Conjur::ConjurConfig).to receive(:tenant_name).and_return("my_tenant") + end + after(:each) do + subject.send(:purge_result) + end + it "returns 200" do + allow_any_instance_of(Aws::AppConfigDataClient).to receive(:pull_from_app_config).and_return(app_config_data) + get("/features", env: token_auth_header(role: @current_user, is_user: true)) + expect(response.code).to eq("200") + resp = JSON.parse(response.body) + expect(resp).to eq(%w[FEATURE1 FEATURE3 FEATURE4 FEATURE7]) + end + + it "app config is invoked once despite multiple calls" do + expect_any_instance_of(Aws::AppConfigDataClient).to receive(:pull_from_app_config).once.and_return(app_config_data) + get("/features", env: token_auth_header(role: @current_user, is_user: true)) + get("/features", env: token_auth_header(role: @current_user, is_user: true)) + end + + it "returns 422 when app config data is invalid" do + allow_any_instance_of(Aws::AppConfigDataClient).to receive(:pull_from_app_config).and_return("invalid json") + get("/features", env: token_auth_header(role: @current_user, is_user: true)) + expect(response.code).to eq("422") + end + end + end +end From f844c803242e6d1e2f45825aefb11cb946a9ea33 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Sun, 24 Sep 2023 09:53:19 +0300 Subject: [PATCH 123/665] ONYX-43770: In show method don't send audit when request coming from UI --- app/controllers/resources_controller.rb | 36 ++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index cca0245c67..112afa7e37 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -60,12 +60,12 @@ def index end def show - result = resource - audit_show_success - render(json: result) - rescue => e - audit_show_failure(e.message) - raise e + show_audit = true + #If the request came from UI ip and have the header we won't send audit + if is_ip_trusted && request.headers['UI_INTERNAL_CALL'] + show_audit = false + end + show_resource(show_audit) end def permitted_roles @@ -188,4 +188,28 @@ def audit_show_failure(error_message) ) end + def show_resource(show_audit) + result = resource + if show_audit + audit_show_success + end + render(json: result) + rescue => e + if show_audit + audit_show_failure(e.message) + end + raise e + end + + def is_ip_trusted + request_ip = request.headers['HTTP_X_FORWARDED_FOR'].to_s.split(",") + ip_trusted = false + request_ip.each do |x| + if Rack::Request.ip_filter.call(x.strip) + ip_trusted = true + end + end + ip_trusted + end + end From 7383578e3e47707cc0c3a2ec24599302a8abd60d Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Wed, 27 Sep 2023 15:52:23 +0300 Subject: [PATCH 124/665] ONYX-43770: Add cucumber test --- app/controllers/resources_controller.rb | 2 +- cucumber/api/features/resource_show.feature | 17 +++++++++++++++++ .../features/step_definitions/audit_steps.rb | 8 ++++++++ dev/build_n_push.sh | 11 +++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100755 dev/build_n_push.sh diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index 112afa7e37..95e8f9a671 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -62,7 +62,7 @@ def index def show show_audit = true #If the request came from UI ip and have the header we won't send audit - if is_ip_trusted && request.headers['UI_INTERNAL_CALL'] + if is_ip_trusted && request.headers['X-Request-Source']=="UI" show_audit = false end show_resource(show_audit) diff --git a/cucumber/api/features/resource_show.feature b/cucumber/api/features/resource_show.feature index f814372fdb..cec5fda73a 100644 --- a/cucumber/api/features/resource_show.feature +++ b/cucumber/api/features/resource_show.feature @@ -65,4 +65,21 @@ Feature: Fetch resource details. [client@43868 ip="\d+\.\d+\.\d+\.\d+"] [action@43868 result="failure" operation="get"] cucumber:user:alice failed to fetch resource details: Santa 'claus' not found in account 'cucumber' + """ + + @negative @acceptance + Scenario: Trying to show a resource that does not exist with no audit + Given I set the "X-Request-Source" header to "UI" + And I set the "X_FORWARDED_FOR" header to "127.0.0.1" + And I save my place in the audit log file for remote + When I GET "/resources/cucumber/santa/noclaus" + Then the HTTP response status code is 404 + And there is no audit record matching: + """ + <84>1 * * conjur * resource + [auth@43868 user="cucumber:user:alice"] + [subject@43868 resource="cucumber:santa:noclaus"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="failure" operation="get"] + cucumber:user:alice failed to fetch resource details: Santa 'noclaus' not found in account 'cucumber' """ \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/audit_steps.rb b/cucumber/api/features/step_definitions/audit_steps.rb index c5efe00bdc..aa24d0d8d3 100644 --- a/cucumber/api/features/step_definitions/audit_steps.rb +++ b/cucumber/api/features/step_definitions/audit_steps.rb @@ -18,6 +18,14 @@ end end +Then(/^there is no audit record matching:$/) do |given| + if Utils.local_conjur_server + expect(audit_messages).to !include(matching(audit_template(given))) + else + expect(num_matches_since_savepoint(normalized_to_log(given))).to be < 1 + end +end + module CucumberAuditHelper def audit_messages Test::AuditSink.messages.map(&method(:normalized_message)) diff --git a/dev/build_n_push.sh b/dev/build_n_push.sh new file mode 100755 index 0000000000..6c1d14a106 --- /dev/null +++ b/dev/build_n_push.sh @@ -0,0 +1,11 @@ +VERSION=$1 + +bundle config set --local without test:development +AWS_PROFILE=dev aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 238637036211.dkr.ecr.us-east-2.amazonaws.com +docker rmi -f 238637036211.dkr.ecr.us-east-2.amazonaws.com/mgmt-conjur-dev-repository-conjur:$VERSION +docker build -t mgmt-conjur-dev-repository-conjur . +docker tag mgmt-conjur-dev-repository-conjur:latest 238637036211.dkr.ecr.us-east-2.amazonaws.com/mgmt-conjur-dev-repository-conjur:$VERSION +AWS_PROFILE=dev aws ecr batch-delete-image --repository-name mgmt-conjur-dev-repository-conjur --region us-east-2 --image-ids imageTag=$VERSION +docker push 238637036211.dkr.ecr.us-east-2.amazonaws.com/mgmt-conjur-dev-repository-conjur:$VERSION + +bundle config unset --local without \ No newline at end of file From 6d6849006724cc550122efae83eca93a0d27d0d8 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Wed, 27 Sep 2023 17:14:10 +0300 Subject: [PATCH 125/665] ONYX-43770: PR Fixes --- app/controllers/resources_controller.rb | 8 +------- dev/build_n_push.sh | 3 +++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index 95e8f9a671..3402b92f9e 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -203,13 +203,7 @@ def show_resource(show_audit) def is_ip_trusted request_ip = request.headers['HTTP_X_FORWARDED_FOR'].to_s.split(",") - ip_trusted = false - request_ip.each do |x| - if Rack::Request.ip_filter.call(x.strip) - ip_trusted = true - end - end - ip_trusted + request_ip.any?{|x| Rack::Request.ip_filter.call(x.strip)} end end diff --git a/dev/build_n_push.sh b/dev/build_n_push.sh index 6c1d14a106..cf055b477f 100755 --- a/dev/build_n_push.sh +++ b/dev/build_n_push.sh @@ -1,3 +1,6 @@ +#Builds and deploys to ECR the mgmt-conjur-dev-repository-conjur image in region us-east-2 +#Usage from conjur folder run ./dev/build_n_push.sh . aws sso login is required + VERSION=$1 bundle config set --local without test:development From d5442aa2c3e5b2d8a534174fb08cbd7e24247c22 Mon Sep 17 00:00:00 2001 From: ygeva Date: Wed, 27 Sep 2023 12:06:28 +0300 Subject: [PATCH 126/665] Add policies when ephermeral working and fixed error message to not found issuer --- app/models/loader/types.rb | 4 ++-- dev/files/ephemeral-secrets/ephemerals.yaml | 5 +++++ dev/files/ephemeral-secrets/issuers.yaml | 6 ++++++ dev/start | 16 +++++++++++++++- spec/models/loader/types.rb | 4 ++-- 5 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 dev/files/ephemeral-secrets/ephemerals.yaml create mode 100644 dev/files/ephemeral-secrets/issuers.yaml diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index a67aa8bf2a..be5db84cc7 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -317,8 +317,8 @@ def verify; issuer = Issuer.where(account: @policy_object.account, issuer_id: issuer_id).first if (issuer.nil?) - message = "Ephemeral variable #{self.id} issuer #{issuer_id} is not defined" - raise Exceptions::InvalidPolicyObject.new(self.id, message: message) + issuer_exception_id = "#{@policy_object.account}:issuer:#{issuer_id}" + raise Exceptions::RecordNotFound, issuer_exception_id end resource_id = @policy_object.account + ":policy:conjur/issuers/" + issuer_id diff --git a/dev/files/ephemeral-secrets/ephemerals.yaml b/dev/files/ephemeral-secrets/ephemerals.yaml new file mode 100644 index 0000000000..5311973782 --- /dev/null +++ b/dev/files/ephemeral-secrets/ephemerals.yaml @@ -0,0 +1,5 @@ +- !policy + id: data + body: + - !policy + id: ephemerals \ No newline at end of file diff --git a/dev/files/ephemeral-secrets/issuers.yaml b/dev/files/ephemeral-secrets/issuers.yaml new file mode 100644 index 0000000000..0ed7cdf92a --- /dev/null +++ b/dev/files/ephemeral-secrets/issuers.yaml @@ -0,0 +1,6 @@ + +- !policy + id: conjur + body: + - !policy + id: issuers \ No newline at end of file diff --git a/dev/start b/dev/start index 43433099d0..71900e2f93 100755 --- a/dev/start +++ b/dev/start @@ -38,6 +38,7 @@ ENABLE_OIDC_IDENTITY=false ENABLE_OIDC_KEYCLOAK=false ENABLE_OIDC_OKTA=false ENABLE_ROTATORS=false +ENABLE_EPHEMERAL_SECRET=false IDENTITY_USER="" declare -a required_envvars @@ -78,7 +79,7 @@ main() { init_jwt init_oidc init_rotators - + init_ephemeral_secrets # Updates CONJUR_AUTHENTICATORS and restarts required services. start_auth_services create_alice @@ -112,6 +113,7 @@ Usage: start [options] authn-oidc/keycloak2 uses AuthnOIDC V2 (based on the Authz Code with PKCE flow). --oidc-okta Adds to authn-oidc okta static env configuration + --ephemeral Starts with ephemeral secrets Conjur cloud policies included --rotators Starts a cucumber and test postgres container. Drops you into the cucumber container. You then manually start 'conjurctl server' in another tab. @@ -134,6 +136,7 @@ parse_options() { --oidc-identity ) ENABLE_AUTHN_OIDC=true ; ENABLE_OIDC_IDENTITY=true; shift ;; --oidc-keycloak ) ENABLE_AUTHN_OIDC=true ; ENABLE_OIDC_KEYCLOAK=true ; shift ;; --oidc-okta ) ENABLE_AUTHN_OIDC=true ; ENABLE_OIDC_OKTA=true ; shift ;; + --ephemeral ) ENABLE_EPHEMERAL_SECRET=true ; shift ;; --rotators ) ENABLE_ROTATORS=true ; shift ;; * ) if [ -z "$1" ]; then @@ -417,6 +420,17 @@ init_azure() { "/src/conjur-server/ci/authn-azure/policies/azure-operators.yml" } + +init_ephemeral_secrets() { + if [[ $ENABLE_EPHEMERAL_SECRET != true ]]; then + return + fi + client_load_policy \ + "/src/conjur-server/dev/files/ephemeral-secrets/issuers.yaml" + client_load_policy \ + "/src/conjur-server/dev/files/ephemeral-secrets/ephemerals.yaml" +} + init_gcp() { if [[ $ENABLE_AUTHN_GCP = true ]]; then enabled_authenticators="$enabled_authenticators,authn-gcp" diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 60e189beec..17f52dc90a 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -186,7 +186,7 @@ context 'when creating ephemeral variable with ephemerals/issuer annotation' do let(:resource_id) { 'data/ephemerals/myvar2' } let(:issuer_id) { 'aws1' } - it { expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/ephemerals/myvar2 issuer aws1 is not defined" ) } + it { expect { variable.verify }.to raise_error(Exceptions::RecordNotFound,"Issuer 'aws1' not found in account 'conjur'" ) } end end @@ -215,7 +215,7 @@ it "raise not InvalidPolicyObject" do allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}).and_return(issuer_object) allow(issuer_object).to receive(:first).and_return(nil) - expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/ephemerals/myvar2 issuer aws1 is not defined") + expect { variable.verify }.to raise_error(Exceptions::RecordNotFound,"Issuer 'aws1' not found in account 'conjur'") end end From f93d453c83ec14f3cf5299cda5143f9f787c9522 Mon Sep 17 00:00:00 2001 From: egvili Date: Wed, 27 Sep 2023 13:43:39 +0300 Subject: [PATCH 127/665] Remove single edge to multi edge migration --- app/db/preview/single_edge_to_multi.rb | 19 ------------------- bin/conjur-cli/commands/server.rb | 2 -- lib/tasks/single-edge.rake | 11 ----------- 3 files changed, 32 deletions(-) delete mode 100644 app/db/preview/single_edge_to_multi.rb delete mode 100644 lib/tasks/single-edge.rake diff --git a/app/db/preview/single_edge_to_multi.rb b/app/db/preview/single_edge_to_multi.rb deleted file mode 100644 index f418ac0b9e..0000000000 --- a/app/db/preview/single_edge_to_multi.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -#TODO: delete once single edge users are migrated to multi -module DB - module Preview - class SingleEdgeToMulti - def find_single_host_id - edge_host = Role.where(:role_id.like('conjur:host:edge/%edge-host-%')).first - edge_installer = Role.where(:role_id.like('conjur:host:edge/%edge-installer-host-%')).first - return get_edge_id(edge_host.role_id) if edge_host && edge_installer - nil - end - - def get_edge_id(hostname) - regex = /(?<=#{'edge-host-'})(.+)/ - hostname.match(regex)&.captures&.first - end - end - end -end \ No newline at end of file diff --git a/bin/conjur-cli/commands/server.rb b/bin/conjur-cli/commands/server.rb index 02ac8a6e61..2716d0ea47 100644 --- a/bin/conjur-cli/commands/server.rb +++ b/bin/conjur-cli/commands/server.rb @@ -42,8 +42,6 @@ def call #fork_authn_local_process fork_rotation_process - system("rake db:single-to-multi") #TODO: delete once single edge users are migrated to multi - # Block until all child processes end wait_for_child_processes end diff --git a/lib/tasks/single-edge.rake b/lib/tasks/single-edge.rake deleted file mode 100644 index 444c0dc64e..0000000000 --- a/lib/tasks/single-edge.rake +++ /dev/null @@ -1,11 +0,0 @@ -require_relative '../../app/db/preview/single_edge_to_multi' -#TODO: delete once single edge users are migrated to multi -namespace :db do - desc "Migrate single edge to multi" - task :"single-to-multi", [] => [:environment] do |t, args| - single_host_id = ::DB::Preview::SingleEdgeToMulti.new.find_single_host_id - if single_host_id - Edge.dataset.insert_conflict(target: [:name]).insert({name: "Edge_01", id: single_host_id, version: "1.0.2", platform: "Podman"}) - end - end -end \ No newline at end of file From e2d75caf54226970d525d49d0b469c0a289b103a Mon Sep 17 00:00:00 2001 From: egvili Date: Mon, 18 Sep 2023 16:17:14 +0300 Subject: [PATCH 128/665] temporary log message for ONYX-35595 --- app/models/audit/log/ruby_adapter.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/audit/log/ruby_adapter.rb b/app/models/audit/log/ruby_adapter.rb index 3d323ede7c..940e933a0d 100644 --- a/app/models/audit/log/ruby_adapter.rb +++ b/app/models/audit/log/ruby_adapter.rb @@ -39,6 +39,8 @@ def log(event) # behavior, so we'd be coupling to an implementation detail by depending # on it. severity = RubySeverity.new(event.severity) + #temporary log message for ONYX-35595 + logger.info("Audit message before sending to fluentd: #{event.to_s}") @ruby_logger.log(severity, event.to_s, ::Audit::Event.progname) end end From 0b63a09ed62adda1d0fc61a6efcacc7e01329530 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Thu, 28 Sep 2023 13:57:08 +0300 Subject: [PATCH 129/665] ONYX-43770: Update Header check to Query Param check --- app/controllers/resources_controller.rb | 4 ++-- cucumber/api/features/resource_show.feature | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index 3402b92f9e..c9a4450253 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -61,8 +61,8 @@ def index def show show_audit = true - #If the request came from UI ip and have the header we won't send audit - if is_ip_trusted && request.headers['X-Request-Source']=="UI" + #If the request came from UI ip and have query param with true value we won't send audit + if is_ip_trusted && params[:show_audit]=="false" show_audit = false end show_resource(show_audit) diff --git a/cucumber/api/features/resource_show.feature b/cucumber/api/features/resource_show.feature index cec5fda73a..09a2794929 100644 --- a/cucumber/api/features/resource_show.feature +++ b/cucumber/api/features/resource_show.feature @@ -69,10 +69,9 @@ Feature: Fetch resource details. @negative @acceptance Scenario: Trying to show a resource that does not exist with no audit - Given I set the "X-Request-Source" header to "UI" - And I set the "X_FORWARDED_FOR" header to "127.0.0.1" + Given I set the "X_FORWARDED_FOR" header to "127.0.0.1" And I save my place in the audit log file for remote - When I GET "/resources/cucumber/santa/noclaus" + When I GET "/resources/cucumber/santa/noclaus?show_audit=false" Then the HTTP response status code is 404 And there is no audit record matching: """ From 03a3fd552828ad2ce95b092f1a9e59696ed3609a Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Thu, 28 Sep 2023 17:27:12 +0300 Subject: [PATCH 130/665] ONYX-43855: Refactor edge controllers to move logic to domain --- .../edge/internal/edge_hosts_controller.rb | 17 +---- .../edge/internal/edge_secrets_controller.rb | 55 +------------- app/domain/edge_logic/replication_handler.rb | 75 +++++++++++++++++++ 3 files changed, 83 insertions(+), 64 deletions(-) create mode 100644 app/domain/edge_logic/replication_handler.rb diff --git a/app/controllers/edge/internal/edge_hosts_controller.rb b/app/controllers/edge/internal/edge_hosts_controller.rb index 0c1b467609..4d3e61c268 100644 --- a/app/controllers/edge/internal/edge_hosts_controller.rb +++ b/app/controllers/edge/internal/edge_hosts_controller.rb @@ -1,3 +1,5 @@ +require_relative '../../../domain/edge_logic/replication_handler' + class EdgeHostsController < RestController include AccountValidator include BodyParser @@ -5,6 +7,7 @@ class EdgeHostsController < RestController include EdgeValidator include ExtractEdgeResources include GroupMembershipValidator + include ReplicationHandler def all_hosts logger.info(LogMessages::Endpoints::EndpointRequested.new("all_hosts")) @@ -36,19 +39,7 @@ def all_hosts logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts:count")) render(json: results) else - results = [] - roles_with_creds = scope.eager(:credentials) - hosts = Role.roles_with_annotations(roles_with_creds).all - hosts.each do |host| - hostToReturn = {} - hostToReturn[:id] = host[:role_id] - salt = OpenSSL::Random.random_bytes(32) - hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) - hostToReturn[:salt] = Base64.strict_encode64(salt) - hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } - hostToReturn[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) - results << hostToReturn - end + results = replicate_hosts(scope) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( "all_hosts", limit, diff --git a/app/controllers/edge/internal/edge_secrets_controller.rb b/app/controllers/edge/internal/edge_secrets_controller.rb index 38eeafd72f..2039daf42a 100644 --- a/app/controllers/edge/internal/edge_secrets_controller.rb +++ b/app/controllers/edge/internal/edge_secrets_controller.rb @@ -1,3 +1,5 @@ +require_relative '../../../domain/edge_logic/replication_handler' + class EdgeSecretsController < RestController include AccountValidator include BodyParser @@ -5,6 +7,7 @@ class EdgeSecretsController < RestController include EdgeValidator include ExtractEdgeResources include GroupMembershipValidator + include ReplicationHandler # Return all secrets within offset-limit frame. Default is 0-1000 def all_secrets @@ -35,45 +38,13 @@ def all_secrets logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets:count")) render(json: results) else - results = [] - failed = [] accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') if accepts_base64 response.set_header("Content-Encoding", "base64") end - variables = build_variables_map(limit, offset, options) - - variables.each do |id, variable| - variableToReturn = {} - variableToReturn[:id] = id - variableToReturn[:owner] = variable[:owner_id] - variableToReturn[:permissions] = [] - Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| - permission = {} - permission[:privilege] = row[:privilege] - permission[:resource] = row[:resource_id] - permission[:role] = row[:role_id] - permission[:policy] = row[:policy_id] - variableToReturn[:permissions].append(permission) - end - secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) - variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value - variableToReturn[:version] = variable[:version] - variableToReturn[:versions] = [] - value = { - "version": variableToReturn[:version], - "value": variableToReturn[:value] - } - variableToReturn[:versions] << value - begin - JSON.generate(variableToReturn) - results << variableToReturn - rescue => e - failed << { "id": id } - end + results, failed = replicate_secrets(limit, offset, options, accepts_base64) - end logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( "all_secrets", limit, @@ -92,22 +63,4 @@ def all_secrets end end - - private - - def build_variables_map(limit, offset, options) - variables = {} - - Sequel::Model.db.fetch("SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE '" + options[:account] + ":variable:data/%') ORDER BY resource_id LIMIT " + limit.to_s + " OFFSET " + offset.to_s + ") AS res ON (res.resource_id = secrets.resource_id)") do |row| - if variables.key?(row[:resource_id]) - if row[:version] > variables[row[:resource_id]][:version] - variables[row[:resource_id]] = row - end - else - variables[row[:resource_id]] = row - end - end - variables - end - end \ No newline at end of file diff --git a/app/domain/edge_logic/replication_handler.rb b/app/domain/edge_logic/replication_handler.rb new file mode 100644 index 0000000000..cab830ba41 --- /dev/null +++ b/app/domain/edge_logic/replication_handler.rb @@ -0,0 +1,75 @@ +module ReplicationHandler + + def replicate_hosts(scope) + results = [] + roles_with_creds = scope.eager(:credentials) + hosts = Role.roles_with_annotations(roles_with_creds).all + hosts.each do |host| + hostToReturn = {} + hostToReturn[:id] = host[:role_id] + salt = OpenSSL::Random.random_bytes(32) + hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + hostToReturn[:salt] = Base64.strict_encode64(salt) + hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + hostToReturn[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) + results << hostToReturn + end + results + end + + def replicate_secrets(limit, offset, options, accepts_base64) + results = [] + failed = [] + + variables = build_variables_map(limit, offset, options) + + variables.each do |id, variable| + variableToReturn = {} + variableToReturn[:id] = id + variableToReturn[:owner] = variable[:owner_id] + variableToReturn[:permissions] = [] + Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| + permission = {} + permission[:privilege] = row[:privilege] + permission[:resource] = row[:resource_id] + permission[:role] = row[:role_id] + permission[:policy] = row[:policy_id] + variableToReturn[:permissions].append(permission) + end + secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) + variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value + variableToReturn[:version] = variable[:version] + variableToReturn[:versions] = [] + value = { + "version": variableToReturn[:version], + "value": variableToReturn[:value] + } + variableToReturn[:versions] << value + begin + JSON.generate(variableToReturn) + results << variableToReturn + rescue => e + failed << { "id": id } + end + end + + [results, failed] + end + + private + + def build_variables_map(limit, offset, options) + variables = {} + + Sequel::Model.db.fetch("SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE '" + options[:account] + ":variable:data/%') ORDER BY resource_id LIMIT " + limit.to_s + " OFFSET " + offset.to_s + ") AS res ON (res.resource_id = secrets.resource_id)") do |row| + if variables.key?(row[:resource_id]) + if row[:version] > variables[row[:resource_id]][:version] + variables[row[:resource_id]] = row + end + else + variables[row[:resource_id]] = row + end + end + variables + end +end \ No newline at end of file From 3f860f479205001bf0831d3ed71c530ecdd0522d Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Sun, 1 Oct 2023 14:28:27 +0300 Subject: [PATCH 131/665] ONYX-42066: Add length validations on workload name --- app/controllers/concerns/params_validator.rb | 4 +- .../edge/api/edge_creation_controller.rb | 2 +- app/controllers/workload_controller.rb | 10 +++-- .../edge/api/edge_creation_controller_spec.rb | 3 +- spec/controllers/workload_controller_spec.rb | 41 ++++++++++++++++++- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/app/controllers/concerns/params_validator.rb b/app/controllers/concerns/params_validator.rb index 0a4008eb0d..8deca06ed4 100644 --- a/app/controllers/concerns/params_validator.rb +++ b/app/controllers/concerns/params_validator.rb @@ -21,8 +21,8 @@ def numeric_validator @numeric_validator ||= ->(k, v){ v.is_a?(Numeric)} end - def string_length_validator - @string_length_validator ||= ->(k, v){(v.is_a?(String) && v.length <= 20)} + def string_length_validator(min_length=0, max_length=20) + @string_length_validator ||= ->(k, v){(v.is_a?(String) && v.length <= max_length && v.length >= min_length)} end end diff --git a/app/controllers/edge/api/edge_creation_controller.rb b/app/controllers/edge/api/edge_creation_controller.rb index 9703c6546f..2c7cde38d6 100644 --- a/app/controllers/edge/api/edge_creation_controller.rb +++ b/app/controllers/edge/api/edge_creation_controller.rb @@ -90,7 +90,7 @@ def created_audit(edge_name = "not-found") def validate_name(name) validate_params({"edge_name" => name}, ->(k,v){ !v.nil? && !v.empty? && - v.match?(/^[a-zA-Z0-9_]+$/) && string_length_validator.call(k, v) + v.match?(/^[a-zA-Z0-9_]+$/) && string_length_validator(0, 60).call(k, v) }) end diff --git a/app/controllers/workload_controller.rb b/app/controllers/workload_controller.rb index 9ec4bffa39..acbfb94b7f 100644 --- a/app/controllers/workload_controller.rb +++ b/app/controllers/workload_controller.rb @@ -8,6 +8,7 @@ class WorkloadController < RestController include FindPolicyResource include PolicyAudit include PolicyWrapper + include ParamsValidator before_action :current_user before_action :find_or_create_root_policy @@ -46,10 +47,11 @@ def post private -def validateId(id) - if id.nil? || id.empty? - raise ApplicationController::UnprocessableEntity, "id param is missing in body, must not be blank." - end +def validateId(name) + validate_params({"workload_name" => name}, ->(k,v){ + !v.nil? && !v.empty? && + v.match?(/^[a-zA-Z0-9_-]+$/) && string_length_validator(3, 60).call(k, v) + }) end def input_workload_create(json_body) diff --git a/spec/controllers/edge/api/edge_creation_controller_spec.rb b/spec/controllers/edge/api/edge_creation_controller_spec.rb index 2c117a5f04..e744670ad6 100644 --- a/spec/controllers/edge/api/edge_creation_controller_spec.rb +++ b/spec/controllers/edge/api/edge_creation_controller_spec.rb @@ -24,11 +24,12 @@ it "Edge names are validated" do expect { subject.send(:validate_name, "Edgy") }.to_not raise_error expect { subject.send(:validate_name, "Edgy_05") }.to_not raise_error + expect { subject.send(:validate_name, "a") }.to_not raise_error expect { subject.send(:validate_name, nil) }.to raise_error expect { subject.send(:validate_name, "") }.to raise_error expect { subject.send(:validate_name, "Edgy!") }.to raise_error - expect { subject.send(:validate_name, "SuperExtremelyLongEdgeName") }.to raise_error + expect { subject.send(:validate_name, "SuperExtremelyLongEdgeName11111111111111111111111111111111111111111111111111") }.to raise_error end end diff --git a/spec/controllers/workload_controller_spec.rb b/spec/controllers/workload_controller_spec.rb index 3d7b813aa5..fe1f5c7318 100644 --- a/spec/controllers/workload_controller_spec.rb +++ b/spec/controllers/workload_controller_spec.rb @@ -79,7 +79,7 @@ context "when user send body with id only" do let(:payload_create_hosts) do <<~BODY - { "id": "new-host" } + { "id": "new-host_3" } BODY end it 'returns created' do @@ -175,7 +175,7 @@ end - context "empty id or body" do + context "workload name validation" do let(:payload_empty) do <<~BODY { @@ -211,6 +211,43 @@ ) assert_response :unprocessable_entity end + let(:payload_short) do + <<~BODY + { + "id": "ab" + } + BODY + end + it "short id return unprocessable_entity" do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_short, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :unprocessable_entity + end + let(:payload_long) do + <<~BODY + { + "id": "SuperExtremelyLongWorkloadName11111111111111111111111111111111111111111111111111" + } + BODY + end + it "long id return unprocessable_entity" do + post("/hosts/rspec/dev", + env: token_auth_header(role: alice_user).merge( + { + 'RAW_POST_DATA' => payload_long, + 'CONTENT_TYPE' => "application/json" + } + ) + ) + assert_response :unprocessable_entity + end + end From c3c4b4e2a65c4a72084bab2b363a4224612f7583 Mon Sep 17 00:00:00 2001 From: nofarvered Date: Sun, 1 Oct 2023 16:32:07 +0300 Subject: [PATCH 132/665] trying to add log before sending sudit to fluentd --- app/domain/logs.rb | 5 +++++ app/models/audit/log/ruby_adapter.rb | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/domain/logs.rb b/app/domain/logs.rb index a53bc2a474..eceaf54616 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -844,6 +844,11 @@ module Secrets # This are log messages so its okay there are many # :reek:TooManyConstants module Util + #temporary message + LogBeforeFluentd = ::Util::TrackableLogMessageClass.new( + msg: "Audit message before sending to fluentd: {0} - event", + code: "CONJ00201D" + ) RateLimitedCacheUpdated = ::Util::TrackableLogMessageClass.new( msg: "Rate limited cache updated successfully", diff --git a/app/models/audit/log/ruby_adapter.rb b/app/models/audit/log/ruby_adapter.rb index 940e933a0d..7eadcaba27 100644 --- a/app/models/audit/log/ruby_adapter.rb +++ b/app/models/audit/log/ruby_adapter.rb @@ -40,7 +40,9 @@ def log(event) # on it. severity = RubySeverity.new(event.severity) #temporary log message for ONYX-35595 - logger.info("Audit message before sending to fluentd: #{event.to_s}") + @logger.info( + LogMessages::Util::LogBeforeFluentd.new(event.to_s) + ) @ruby_logger.log(severity, event.to_s, ::Audit::Event.progname) end end From a54fcf7ba3e9889d7a3ca75faaab25495e1dc680 Mon Sep 17 00:00:00 2001 From: ygeva Date: Mon, 2 Oct 2023 10:11:13 +0300 Subject: [PATCH 133/665] Change of logs --- app/controllers/issuers_controller.rb | 4 ++-- app/models/loader/types.rb | 4 ++-- spec/models/loader/types.rb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/issuers_controller.rb b/app/controllers/issuers_controller.rb index bbf24f55e0..d096b41ebe 100644 --- a/app/controllers/issuers_controller.rb +++ b/app/controllers/issuers_controller.rb @@ -35,7 +35,7 @@ def create modified_at: Sequel::CURRENT_TIMESTAMP, policy_id: "#{params[:account]}:policy:conjur/issuers/#{params[:id]}") - raise ApplicationController::InternalServerError, "Found related variable/s to the given issuer id" if issuer.issuer_variables_exist? + raise ApplicationController::InternalServerError, "Found variables associated with the issuer id" if issuer.issuer_variables_exist? create_issuer_policy({ "id" => params[:id] }) issuer.save @@ -59,7 +59,7 @@ def create } }, status: :bad_request) rescue Sequel::UniqueConstraintViolation => e - logger.error("Issuer [#{params[:id]}] already exists") + logger.error("The issuer [#{params[:id]}] already exists") audit_failure(e, action) issuer_audit_failure(params[:account], params[:id], "add", e.message) raise Exceptions::RecordExists.new("issuer", params[:id]) diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index be5db84cc7..82904b2c5b 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -310,7 +310,7 @@ class Variable < Record def verify; if self.id.start_with?(Issuer::EPHEMERAL_VARIABLE_PREFIX) if self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"].nil? - message = "Ephemeral variable #{self.id} has no issuer annotation" + message = "The ephemeral variable '#{self.id}' has no issuer annotation" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) else issuer_id = self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"] @@ -326,7 +326,7 @@ def verify; end else if !(self.annotations.nil?) && !(self.annotations[Issuer::EPHEMERAL_ANNOTATION_PREFIX + "issuer"].nil?) - message = "Ephemeral variable #{self.id} not in right path" + message = "The ephemeral variable '#{self.id}' is not in the correct path" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end end diff --git a/spec/models/loader/types.rb b/spec/models/loader/types.rb index 17f52dc90a..2564a1fec5 100644 --- a/spec/models/loader/types.rb +++ b/spec/models/loader/types.rb @@ -203,7 +203,7 @@ it "raise not InvalidPolicyObject" do allow(Issuer).to receive(:where).with({:account=>"conjur", :issuer_id=>"aws1"}).and_return(issuer_object) allow(issuer_object).to receive(:first).and_return(nil) - expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/myvar2 not in right path") + expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"The ephemeral variable 'data/myvar2' is not in the correct path") end end @@ -243,7 +243,7 @@ let(:issuer_id) { 'aws1' } let(:issuer_object) { 'issuer' } it "raise not InvalidPolicyObject" do - expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"Ephemeral variable data/myvar2 not in right path") + expect { variable.verify }.to raise_error(Exceptions::InvalidPolicyObject,"The ephemeral variable 'data/myvar2' is not in the correct path") end end From 569679e440da0b1d16458d55109b6481ce8acd4b Mon Sep 17 00:00:00 2001 From: nofarvered Date: Sun, 1 Oct 2023 16:32:07 +0300 Subject: [PATCH 134/665] support rds15 --- Dockerfile | 2 +- app/domain/logs.rb | 6 ------ app/models/audit/log/ruby_adapter.rb | 4 ---- ci/docker-compose.yml | 6 +++--- .../authenticators_k8s/dev/dev_conjur.template.yaml | 2 +- dev/docker-compose.yml | 6 +++--- 6 files changed, 8 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9475f93aad..7eb34d0c5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM cyberark/ubuntu-ruby-fips:2.0.7-618 +FROM cyberark/ubuntu-ruby-fips:2.0.7-697 ENV DEBIAN_FRONTEND=noninteractive \ PORT=80 \ diff --git a/app/domain/logs.rb b/app/domain/logs.rb index eceaf54616..36f17b42b5 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -844,12 +844,6 @@ module Secrets # This are log messages so its okay there are many # :reek:TooManyConstants module Util - #temporary message - LogBeforeFluentd = ::Util::TrackableLogMessageClass.new( - msg: "Audit message before sending to fluentd: {0} - event", - code: "CONJ00201D" - ) - RateLimitedCacheUpdated = ::Util::TrackableLogMessageClass.new( msg: "Rate limited cache updated successfully", code: "CONJ00016D" diff --git a/app/models/audit/log/ruby_adapter.rb b/app/models/audit/log/ruby_adapter.rb index 7eadcaba27..3d323ede7c 100644 --- a/app/models/audit/log/ruby_adapter.rb +++ b/app/models/audit/log/ruby_adapter.rb @@ -39,10 +39,6 @@ def log(event) # behavior, so we'd be coupling to an implementation detail by depending # on it. severity = RubySeverity.new(event.severity) - #temporary log message for ONYX-35595 - @logger.info( - LogMessages::Util::LogBeforeFluentd.new(event.to_s) - ) @ruby_logger.log(severity, event.to_s, ::Audit::Event.progname) end end diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 38f4c67de1..4f9b4b4b46 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -1,7 +1,7 @@ version: "3" services: pg: - image: postgres:10.16 + image: postgres:15 environment: # To avoid the following error: # @@ -15,13 +15,13 @@ services: POSTGRES_HOST_AUTH_METHOD: trust audit: - image: postgres:10.16 + image: postgres:15 environment: # See description on `pg` service for use of POSTGRES_HOST_AUTH_METHOD POSTGRES_HOST_AUTH_METHOD: trust testdb: - image: postgres:10.16 + image: postgres:15 environment: # To avoid the following error: # diff --git a/ci/test_suites/authenticators_k8s/dev/dev_conjur.template.yaml b/ci/test_suites/authenticators_k8s/dev/dev_conjur.template.yaml index 67d586418d..014f7a1b06 100644 --- a/ci/test_suites/authenticators_k8s/dev/dev_conjur.template.yaml +++ b/ci/test_suites/authenticators_k8s/dev/dev_conjur.template.yaml @@ -57,7 +57,7 @@ spec: app: postgres spec: containers: - - image: postgres:10.16 + - image: postgres:15 imagePullPolicy: Always name: postgres env: diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 36efd194ea..c168d67b73 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -1,7 +1,7 @@ version: "3" services: pg: - image: postgres:10.16 + image: postgres:15 environment: # To avoid the following error: # @@ -20,13 +20,13 @@ services: audit: - image: postgres:10.16 + image: postgres:15 environment: # See description on `pg` service for use of POSTGRES_HOST_AUTH_METHOD POSTGRES_HOST_AUTH_METHOD: trust testdb: - image: postgres:10.16 + image: postgres:15 environment: POSTGRES_PASSWORD: postgres_secret From bda8824897a960dda611493d1a66f6d742735c73 Mon Sep 17 00:00:00 2001 From: Neil King Date: Mon, 8 May 2023 19:44:27 +0300 Subject: [PATCH 135/665] Support running cucumber tests in parallel: - Spawn multiple processes to execute cucumber tests in parallel. Each process contains it's own indipendent conjur instance and postgresql database to avoid colisions. - Dynamically alter Ruby ENV variables based on the process executing the cucumber feature tests. - Dockerfile has been altered to support replicated services for parallel tests (this has not been altered to be dynamic and consists of static changes). - Alter cucumber.yml profiles to support parallel_tests ruby gem cmd usage The following tests have not been parallelized: - authenticators_k8s - rspec tests (cherry picked from commit 116aacbcc2413993930f972f1109dcb9ecc6d208) --- Gemfile | 1 + Gemfile.lock | 3 + Jenkinsfile | 433 +++++++++++++++--- ci/docker-compose.yml | 70 ++- ci/oauth/keycloak/keycloak_functions.sh | 14 +- ci/shared.sh | 103 ++++- ci/test | 5 +- ci/test_suites/authenticators_jwt/test | 4 +- ci/test_suites/authenticators_oidc/test | 4 +- ci/test_suites/rotators/test | 13 +- config/environments/development.rb | 4 +- config/environments/test.rb | 11 + cucumber.yml | 13 +- .../features/support/authenticator_helpers.rb | 2 +- .../features/support/env.rb | 13 +- .../features/support/hooks.rb | 9 + cucumber/api/features/support/env.rb | 11 + cucumber/api/features/support/hooks.rb | 9 + .../authenticators/features/support/hooks.rb | 9 + .../features/authn_jwt_token_schema.feature | 12 +- .../features/support/env.rb | 11 + .../features/support/hooks.rb | 9 + cucumber/policy/features/support/client.rb | 2 +- cucumber/policy/features/support/env.rb | 13 +- cucumber/policy/features/support/hooks.rb | 9 + .../step_definitions/rotator_steps.rb | 3 + cucumber/rotators/features/support/env.rb | 13 +- .../features/support/rotator_helpers.rb | 4 +- engines/conjur_audit/config/routes.rb | 11 + .../spec/dummy/config/environments/test.rb | 11 + 30 files changed, 711 insertions(+), 118 deletions(-) diff --git a/Gemfile b/Gemfile index cb02e92dd5..0301f54f33 100644 --- a/Gemfile +++ b/Gemfile @@ -92,6 +92,7 @@ group :development, :test do gem 'faye-websocket' gem 'net-ssh' gem 'parallel' + gem 'parallel_tests' gem 'pry-byebug' gem 'pry-rails' gem 'rails-controller-testing' diff --git a/Gemfile.lock b/Gemfile.lock index 5a9ed1cbcf..29b04ee9dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -322,6 +322,8 @@ GEM validate_url webfinger (>= 1.0.1) parallel (1.21.0) + parallel_tests (4.2.0) + parallel parser (3.0.3.2) ast (~> 2.4.1) pg (1.2.3) @@ -552,6 +554,7 @@ DEPENDENCIES nokogiri (>= 1.8.2) openid_connect parallel + parallel_tests pg pry-byebug pry-rails diff --git a/Jenkinsfile b/Jenkinsfile index 00c8ec63ef..98a13c2f80 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -46,6 +46,10 @@ These are defined in runConjurTests, and also include the one-offs gcp_authenticator */ +// Break the total number of tests into a subset of tests. +// This will give 3 nested lists of tests to run, which is +// distributed over 3 jenkins agents. +def NESTED_ARRAY_OF_TESTS_TO_RUN = collateTests() pipeline { agent { label 'executor-v2' } @@ -190,99 +194,283 @@ pipeline { when { expression { params.NIGHTLY } } + agent { label 'executor-v2-rhel-ee' } environment { CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" } stages { - stage('EE FIPS agent tests') { - agent { label 'executor-v2-rhel-ee' } + stage("RSpec - EE FIPS agent tests") { steps { sh(script: 'cat /etc/os-release', label: 'RHEL version') sh(script: 'docker --version', label: 'Docker version') + addNewImagesToAgent() unstash 'version_info' // Catch errors so remaining steps always run. catchError { // Run outside parallel block to avoid external pressure - script { - stage("RSpec - EE FIPS agent tests") { - sh "ci/test rspec" + sh "ci/test rspec" + } + } + } + + stage('EE FIPS parallel') { + parallel { + stage('EE FIPS agent tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]) + ) } } - runConjurTests(params.RUN_ONLY) + steps { + addNewImagesToAgent() + unstash 'version_info' + runConjurTests( + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[0] + ) + stash( + name: 'testResultEE', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } } + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('EE FIPS agent2 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]) + ) + } + } + agent { label 'executor-v2-rhel-ee' } - stash( - name: 'testResultEE', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - gems/conjur-rack/spec/reports/*.xml, - gems/slosilo/spec/reports/*.xml, - cucumber/*/features/reports/**/*.xml - ''' - ) - } + environment { + CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + } - post { - always { - dir('ee-test'){ - unstash 'testResultEE' + steps { + addNewImagesToAgent() + unstash 'version_info' + runConjurTests( + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[1] + ) + stash( + name: 'testResultEE2', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } + } + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('EE FIPS agent3 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]) + ) + } } - archiveArtifacts( - artifacts: "ee-test/cucumber/*/*.*", - fingerprint: false, - allowEmptyArchive: true - ) + agent { label 'executor-v2-rhel-ee' } - archiveArtifacts( - artifacts: "ee-test/container_logs/*/*", - fingerprint: false, - allowEmptyArchive: true - ) + environment { + CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + } - publishHTML( - reportDir: 'ee-test/cucumber', - reportFiles: ''' - api/cucumber_results.html, - authenticators_config/cucumber_results.html, - authenticators_azure/cucumber_results.html, - authenticators_ldap/cucumber_results.html, - authenticators_oidc/cucumber_results.html, - authenticators_jwt/cucumber_results.html, - authenticators_status/cucumber_results.html - policy/cucumber_results.html, - rotators/cucumber_results.html - ''', - reportName: 'EE Integration reports', - reportTitles: '', - allowMissing: false, - alwaysLinkToLastBuild: true, - keepAll: true - ) + steps { + addNewImagesToAgent() + unstash 'version_info' + runConjurTests( + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[2] + ) + stash( + name: 'testResultEE3', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } } } } } + post { + always { + script { + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]))) { + dir('ee-test'){ + unstash 'testResultEE' + } + } + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]))) { + dir('ee-test'){ + unstash 'testResultEE2' + } + } + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]))) { + dir('ee-test'){ + unstash 'testResultEE3' + } + } + } + + archiveArtifacts( + artifacts: "ee-test/cucumber/*/*.*", + fingerprint: false, + allowEmptyArchive: true + ) + + archiveArtifacts( + artifacts: "ee-test/container_logs/*/*", + fingerprint: false, + allowEmptyArchive: true + ) + + publishHTML( + reportDir: 'ee-test/cucumber', + reportFiles: ''' + api/cucumber_results.html, + authenticators_config/cucumber_results.html, + authenticators_azure/cucumber_results.html, + authenticators_ldap/cucumber_results.html, + authenticators_oidc/cucumber_results.html, + authenticators_jwt/cucumber_results.html, + authenticators_status/cucumber_results.html + policy/cucumber_results.html, + rotators/cucumber_results.html + ''', + reportName: 'EE Integration reports', + reportTitles: '', + allowMissing: false, + alwaysLinkToLastBuild: true, + keepAll: true + ) + } + } } stage('Run environment tests in parallel') { parallel { stage('Standard agent tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]) + ) + } + } + environment { CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" } steps { - sh(script: 'cat /etc/os-release', label: 'RHEL version') + sh(script: 'cat /etc/os-release', label: 'Ubuntu version') sh(script: 'docker --version', label: 'Docker version') - runConjurTests(params.RUN_ONLY) + runConjurTests( + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[0] + ) + } + } + + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('Standard agent2 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]) + ) + } + } + + agent { label 'executor-v2' } + environment { + CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + } + + steps { + addNewImagesToAgent() + unstash 'version_info' + runConjurTests(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]) + stash( + name: 'standardTestResult2', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } + } + + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('Standard agent3 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]) + ) + } + } + + agent { label 'executor-v2' } + environment { + CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + } + + steps { + addNewImagesToAgent() + unstash 'version_info' + runConjurTests( + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[2] + ) + stash( + name: 'standardTestResult3', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml, + ci/test_suites/*/output/* + ''' + ) } } @@ -310,6 +498,7 @@ pipeline { } steps { + addNewImagesToAgent() unstash 'version_info' // Grant access to this Jenkins agent's IP to AWS security groups // This is required for access to the internal docker registry @@ -529,6 +718,14 @@ pipeline { always { script { + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]))) { + unstash 'standardTestResult2' + } + + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]))) { + unstash 'standardTestResult3' + } + // Only unstash azure if it ran. if (testShouldRun(params.RUN_ONLY, "azure_authenticator")) { unstash 'testResultAzure' @@ -606,7 +803,7 @@ pipeline { // cleanupAndNotify(buildStatus, slackChannel, additionalMessage, ticket) cleanupAndNotify( currentBuild.currentResult, - 'Team - Palm Tree', + '#conjur-core', "${(params.NIGHTLY ? 'nightly' : '')}", true ) @@ -620,12 +817,24 @@ pipeline { // TODO: Do we want to move any of these functions to a separate file? +def addNewImagesToAgent() { + // Pull and retag existing images onto new Jenkins agent + sh """ + docker pull registry.tld/conjur:${tagWithSHA()} + docker pull registry.tld/conjur-ubi:${tagWithSHA()} + docker pull registry.tld/conjur-test:${tagWithSHA()} + docker tag registry.tld/conjur:${tagWithSHA()} conjur:${tagWithSHA()} + docker tag registry.tld/conjur-ubi:${tagWithSHA()} conjur-ubi:${tagWithSHA()} + docker tag registry.tld/conjur-test:${tagWithSHA()} conjur-test:${tagWithSHA()} + """ +} + // Possible minor optimization: Could memoize this. Need to verify it's not // shared across builds. def tagWithSHA() { sh( returnStdout: true, - script: 'echo $(git rev-parse --short=8 HEAD)' + script: 'echo -n $(git rev-parse --short=8 HEAD)' ) } @@ -641,11 +850,37 @@ def testShouldRun(run_only_str, test) { return run_only_str == '' || run_only_str.split().contains(test) } -// "run_only_str" is a space-separated string specifying the subset of tests to -// run. If it's empty, all tests are run. -def runConjurTests(run_only_str) { +def testShouldRunOnAgent(run_only_str, agent_specific_tests) { + return run_only_str == '' || ! agent_specific_tests.isEmpty() +} + +def runSpecificTestOnAgent(run_only_str, agent_specific_tests) { + // runSpecificTestOnAgent allows a subset of tests to be ran + // on an agent, determined by the agent's assigned subset of + // tests it normally runs. + + // Args: + // run_only_str: a space seperated string of test names + // agent_specific_tests: an array of tests that the agent + // is assigned to run + + // Returns: + // An array of test names to run + def run_only_tests = [] + def find_tests = run_only_str.split() + + find_tests.each { run_only_test -> + agent_specific_tests.find { agent_test -> + if (agent_test.contains(run_only_test)) { + run_only_tests.add(run_only_test) + } + } + } + return run_only_tests +} - all_tests = [ +def conjurTests() { + return [ "authenticators_config": [ "Authenticators Config - ${env.STAGE_NAME}": { sh 'ci/test authenticators_config' @@ -656,16 +891,16 @@ def runConjurTests(run_only_str) { sh 'ci/test authenticators_status' } ], -// "authenticators_k8s": [ -// "K8s Authenticator - ${env.STAGE_NAME}": { -// sh 'ci/test authenticators_k8s' -// } -// ], // "authenticators_ldap": [ // "LDAP Authenticator - ${env.STAGE_NAME}": { // sh 'ci/test authenticators_ldap' // } // ], + "api": [ + "API - ${env.STAGE_NAME}": { + sh 'ci/test api' + } + ], "authenticators_oidc": [ "OIDC Authenticator - ${env.STAGE_NAME}": { sh 'summon -f ./ci/test_suites/authenticators_oidc/secrets.yml -e ci ci/test authenticators_oidc' @@ -681,16 +916,16 @@ def runConjurTests(run_only_str) { sh 'ci/test policy' } ], - "api": [ - "API - ${env.STAGE_NAME}": { - sh 'ci/test api' - } - ], "rotators": [ "Rotators - ${env.STAGE_NAME}": { sh 'ci/test rotators' } ], +// "authenticators_k8s": [ +// "K8s Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_k8s' +// } +// ], "rspec_audit": [ "Audit - ${env.STAGE_NAME}": { sh 'ci/test rspec_audit' @@ -712,13 +947,26 @@ def runConjurTests(run_only_str) { } ] ] +} + +def runConjurTests(run_only_str, cuke_test_names) { + // runConjurTests will build a parallel Jenkins block of code + // that will run the specified cucumber test stages. - // Filter for the tests we want run, if requested. - parallel_tests = all_tests - tests = run_only_str.split() + // Args: + // cuke_test_names an array of test names to run. - if (tests.size() > 0) { - parallel_tests = all_tests.subMap(tests) + // Returns: + // A Jenkins block of parallel code. + + def all_tests = conjurTests() + def run_only_tests = runSpecificTestOnAgent(run_only_str, cuke_test_names) + def parallel_tests = all_tests + + if (run_only_tests.isEmpty()) { + parallel_tests = all_tests.subMap(cuke_test_names) + } else { + parallel_tests = all_tests.subMap(run_only_tests) } // Create the parallel pipeline. @@ -726,6 +974,7 @@ def runConjurTests(run_only_str) { // Since + merges two maps together, sum() combines the individual values of // parallel_tests into one giant map whose keys are the stage names and // whose values are the blocks to be run. + script { parallel( parallel_tests.values().sum() @@ -733,6 +982,40 @@ def runConjurTests(run_only_str) { } } +def collateTests(jobs_per_agent=4) { + // collateTests will find the names of cucumber tests that should run + // and create a nested list of tests to be ran across mutliple Jenkins + // agents. + + // Args: + // jobs_per_agent: The nested list of tests names will be no more than + // the specified integer. + + // Returns: a nested list of test names. + + def all_tests = conjurTests() + def all_test_names = [] + + all_tests.each{ k, _ -> + all_test_names.add(k) + } + + def parallel_tests = [] + // Create a subset of tests that can be ran by each Jenkins agent + int partitionCount = all_test_names.size() / jobs_per_agent + + partitionCount.times { partitionNumber -> + def start = partitionNumber * jobs_per_agent + def end = start + jobs_per_agent - 1 + parallel_tests.add(all_test_names[start..end]) + } + + if (all_tests.size() % jobs_per_agent) { + parallel_tests.add(all_test_names[partitionCount * jobs_per_agent..-1]) + } + return parallel_tests +} + def defaultCucumberFilterTags(env) { if(env.BRANCH_NAME == 'master' || env.BRANCH_NAME == 'conjur-cloud' || env.TAG_NAME?.trim()) { // If this is a master or tag build, we want to run all of the tests. So diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 4f9b4b4b46..5c3964f691 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -14,6 +14,20 @@ services: # PostgreSQL documentation about "trust" POSTGRES_HOST_AUTH_METHOD: trust + pg2: + image: postgres:15 + environment: + # To avoid the following error: + # + # Error: Database is uninitialized and superuser password is not + # specified. You must specify POSTGRES_PASSWORD for the superuser. Use + # "-e POSTGRES_PASSWORD=password" to set it in "docker run". + # + # You may also use POSTGRES_HOST_AUTH_METHOD=trust to allow all + # connections without a password. This is *not* recommended. See + # PostgreSQL documentation about "trust" + POSTGRES_HOST_AUTH_METHOD: trust + audit: image: postgres:15 environment: @@ -34,6 +48,20 @@ services: # PostgreSQL documentation about "trust" POSTGRES_HOST_AUTH_METHOD: trust + testdb2: + image: postgres:15 + environment: + # To avoid the following error: + # + # Error: Database is uninitialized and superuser password is not + # specified. You must specify POSTGRES_PASSWORD for the superuser. Use + # "-e POSTGRES_PASSWORD=password" to set it in "docker run". + # + # You may also use POSTGRES_HOST_AUTH_METHOD=trust to allow all + # connections without a password. This is *not* recommended. See + # PostgreSQL documentation about "trust" + POSTGRES_HOST_AUTH_METHOD: trust + conjur: image: "conjur-test:${TAG}" environment: @@ -67,14 +95,45 @@ services: - ldap-server - keycloak + conjur2: + image: "conjur-test:${TAG}" + environment: + DATABASE_URL: postgres://postgres@pg2/postgres + CONJUR_ADMIN_PASSWORD: ADmin123!!!! + CONJUR_ACCOUNT: cucumber + CONJUR_DATA_KEY: + RAILS_ENV: + REQUIRE_SIMPLECOV: "true" + CONJUR_LOG_LEVEL: debug + CONJUR_AUTHENTICATORS: authn-ldap/test,authn-ldap/secure,authn-oidc/keycloak,authn-oidc,authn-k8s/test,authn-azure/prod,authn-gcp,authn-jwt/raw,authn-jwt/keycloak,authn-oidc/keycloak2,authn-oidc/okta-2 + LDAP_URI: ldap://ldap-server:389 + LDAP_BASE: dc=conjur,dc=net + LDAP_FILTER: '(uid=%s)' + LDAP_BINDDN: cn=admin,dc=conjur,dc=net + LDAP_BINDPW: ldapsecret + WEB_CONCURRENCY: 0 + RAILS_MAX_THREADS: 10 + command: server + volumes: + # TODO: authenticators_oidc/test has a dep on this + - authn-local2:/run/authn-local + - ./oauth/keycloak:/oauth/keycloak/scripts + - ./ldap-certs:/ldap-certs:ro + - log-volume:/opt/conjur-server/log + - ../coverage:/opt/conjur-server/coverage + expose: + - "80" + links: + - pg2 + - ldap-server + - keycloak + cucumber: image: conjur-test:$TAG entrypoint: bash working_dir: /src/conjur-server environment: - CONJUR_APPLIANCE_URL: http://conjur CONJUR_ACCOUNT: cucumber - DATABASE_URL: postgres://postgres@pg/postgres AUDIT_DATABASE_URL: RAILS_ENV: test CONJUR_LOG_LEVEL: debug @@ -88,14 +147,18 @@ services: volumes: - ..:/src/conjur-server - authn-local:/run/authn-local + - authn-local2:/run/authn-local - ./ldap-certs:/ldap-certs:ro - log-volume:/src/conjur-server/log - jwks-volume:/var/jwks - ./oauth/keycloak:/oauth/keycloak/scripts links: - conjur + - conjur2 - pg + - pg2 - testdb + - testdb2 - keycloak ldap-server: @@ -168,5 +231,6 @@ services: volumes: authn-local: + authn-local2: log-volume: - jwks-volume: \ No newline at end of file + jwks-volume: diff --git a/ci/oauth/keycloak/keycloak_functions.sh b/ci/oauth/keycloak/keycloak_functions.sh index c01216f1d8..dada055816 100644 --- a/ci/oauth/keycloak/keycloak_functions.sh +++ b/ci/oauth/keycloak/keycloak_functions.sh @@ -2,6 +2,10 @@ KEYCLOAK_SERVICE_NAME="keycloak" +# This is executed by the main "ci/test" script after cd-ing into "ci". +# shellcheck disable=SC1091 +source "./shared.sh" + # Note: the single arg is a nameref, which this function sets to an array # containing items of the form "KEY=VAL". function _hydrate_keycloak_env_args() { @@ -84,6 +88,12 @@ function fetch_keycloak_certificate() { # there's a dep on the docker-compose.yml volumes. # Fetch SSL cert to communicate with keycloak (OIDC provider). echo "Initialize keycloak certificate in conjur server" - docker-compose exec -T \ - conjur /oauth/keycloak/scripts/fetch_certificate + + local parallel_services + read -ra parallel_services <<< "$(get_parallel_services 'conjur')" + + for parallel_service in "${parallel_services[@]}"; do + docker-compose exec -T \ + "${parallel_service}" /oauth/keycloak/scripts/fetch_certificate + done } diff --git a/ci/shared.sh b/ci/shared.sh index 5de62b47bc..537c831613 100644 --- a/ci/shared.sh +++ b/ci/shared.sh @@ -2,6 +2,38 @@ export REPORT_ROOT=/src/conjur-server +# Sets the number of parallel processes for cucumber tests +# Due to naming conventions for parallel_cucumber this begins at 1 NOT 0 +PARALLEL_PROCESSES=2 + +get_parallel_services() { + # get_parallel_services converts docker service names + # to the appropriate naming conventions expected for + # parallel cucumber tests. + + # Args: + # $1: A string of space delimited docker service name(s) + + # Returns: + # An array of docker service names matching the expected + # parallel cucumber naming convention. + local services + local parallel_services + read -ra services <<< "$1" + + for service in "${services[@]}"; do + for (( i=1; i<=PARALLEL_PROCESSES; i++ )); do + if (( i == 1 )) ; then + parallel_services+=("$service") + else + parallel_services+=("$service${i}") + fi + done + done + + echo "${parallel_services[@]}" +} + # Note: This function is long but purposefully not split up. None of its parts # are re-used, and the split-up version is harder to follow and duplicates # argument processing. @@ -30,12 +62,25 @@ _run_cucumber_tests() { echo "Start all services..." - docker-compose up --no-deps --no-recreate -d pg conjur "${services[@]}" - docker-compose exec -T conjur conjurctl wait --retries 180 + local parallel_services + read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" + + if (( ${#services[@]} )); then + docker-compose up --no-deps --no-recreate -d "${parallel_services[@]}" "${services[@]}" + else + docker-compose up --no-deps --no-recreate -d "${parallel_services[@]}" + fi + + read -ra parallel_services <<< "$(get_parallel_services 'conjur')" + for parallel_service in "${parallel_services[@]}"; do + docker-compose exec -T "$parallel_service" conjurctl wait --retries 180 + done echo "Create cucumber account..." - docker-compose exec -T conjur conjurctl account create cucumber + for parallel_service in "${parallel_services[@]}"; do + docker-compose exec -T "$parallel_service" conjurctl account create cucumber + done # Stage 2: Prepare cucumber environment args # ----------------------------------------------------------- @@ -56,16 +101,31 @@ _run_cucumber_tests() { env_var_flags+=(-e "$item") done + # Generate api key ENV variables based on the + # number of parallel processes. + for (( i=1; i <= ${#parallel_services[@]}; ++i )); do + index=$(( i - 1 )) + if (( i == 1 )) ; then + api_keys+=("CONJUR_AUTHN_API_KEY=$(_get_api_key "${parallel_services[$index]}")") + else + api_keys+=("CONJUR_AUTHN_API_KEY${i}=$(_get_api_key "${parallel_services[$index]}")") + fi + done + # Add the cucumber env vars that we always want to send. # Note: These are args for docker-compose run, and as such the right hand # sides of the = do NOT require escaped quotes. docker-compose takes the # entire arg, splits on the =, and uses the rhs as the value, env_var_flags+=( - -e "CONJUR_AUTHN_API_KEY=$(_get_api_key)" -e "CUCUMBER_NETWORK=$(_find_cucumber_network)" -e "CUCUMBER_FILTER_TAGS=$CUCUMBER_FILTER_TAGS" ) + # Add parallel process api_keys to the env_var_flags + for api_key in "${api_keys[@]}"; do + env_var_flags+=(-e "$api_key") + done + # If there's no tty (e.g. we're running as a Jenkins job), pass -T to # docker-compose. run_flags=(--no-deps --rm) @@ -84,16 +144,23 @@ _run_cucumber_tests() { # Stage 3: Run Cucumber # ----------------------------------------------------------- + echo "ENV_ARG_FN: ${env_arg_fn}" >&2 + echo "RUN_FLAGS: ${run_flags[*]}" >&2 + echo "ENV_VAR_FLAGS: ${env_var_flags[*]}" >&2 + echo "CUCUMBER TAGS: ${cucumber_tags_arg}" >&2 + echo "CUCUMBER PROFILE: ${profile}" >&2 + + + # Have to add tags in profile for parallel to run properly + # ${cucumber_tags_arg} should overwrite the profile tags in a way for @smoke to work correctly docker-compose run "${run_flags[@]}" "${env_var_flags[@]}" \ cucumber -ec "\ /oauth/keycloak/scripts/fetch_certificate && - bundle exec cucumber \ - --strict \ - ${cucumber_tags_arg} \ - -p \"$profile\" \ + bundle exec parallel_cucumber . -n ${PARALLEL_PROCESSES} \ + -o '--strict --profile \"${profile}\" ${cucumber_tags_arg} \ --format json --out \"cucumber/$profile/cucumber_results.json\" \ --format html --out \"cucumber/$profile/cucumber_results.html\" \ - --format junit --out \"cucumber/$profile/features/reports\"" + --format junit --out \"cucumber/$profile/features/reports\"'" # Stage 4: Coverage results # ----------------------------------------------------------- @@ -102,21 +169,26 @@ _run_cucumber_tests() { # killed before ruby, the report doesn't get written. So here we kill the # process to write the report. The container is kept alive using an infinite # sleep in the at_exit hook (see .simplecov). - docker-compose exec -T conjur bash -c "pkill -f 'puma 5'" + for parallel_service in "${parallel_services[@]}"; do + docker-compose exec -T "$parallel_service" bash -c "pkill -f 'puma 5'" + done } _get_api_key() { - docker-compose exec -T conjur conjurctl \ + local service=$1 + + docker-compose exec -T "${service}" conjurctl \ role retrieve-key cucumber:user:admin | tr -d '\r' } _find_cucumber_network() { local net - net=$( - docker inspect "$(docker-compose ps -q conjur)" \ - --format '{{.HostConfig.NetworkMode}}' - ) + # Docker compose conjur/pg services use the same + # network for 1 or more instances so only conjur is passed + # and not other parallel services. + conjur_id=$(docker-compose ps -q conjur) + net=$(docker inspect "${conjur_id}" --format '{{.HostConfig.NetworkMode}}') docker network inspect "$net" \ --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' @@ -180,5 +252,6 @@ start_ldap_server() { echo 'LDAP server failed to start in time' exit 1 fi + echo "Done." } diff --git a/ci/test b/ci/test index cc99051be8..0ba14cbabb 100755 --- a/ci/test +++ b/ci/test @@ -86,9 +86,8 @@ docker_diagnostics() { # its _value_ in the regex. # # Docker Note: container name is always the last field. Hence $NF gets it. - readarray -t cont_names < <( - docker ps --all | awk "/${COMPOSE_PROJECT_NAME}/{print \$NF}" - ) + declare -a cont_names + while IFS=$'\n' read -r line; do cont_names+=("$line"); done < <(docker ps --all | awk "/${COMPOSE_PROJECT_NAME}/{print \$NF}") # Store container logs for archiving. echo "Writing Container logs to" \ diff --git a/ci/test_suites/authenticators_jwt/test b/ci/test_suites/authenticators_jwt/test index e3611a7b2b..db1a27a949 100755 --- a/ci/test_suites/authenticators_jwt/test +++ b/ci/test_suites/authenticators_jwt/test @@ -8,7 +8,9 @@ source "./shared.sh" source "./oauth/keycloak/keycloak_functions.sh" function main() { - docker-compose up --no-deps -d pg conjur jwks jwks_py keycloak + local parallel_services + read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" + docker-compose up --no-deps -d "${parallel_services[@]}" jwks jwks_py keycloak wait_for_keycloak_server create_keycloak_users diff --git a/ci/test_suites/authenticators_oidc/test b/ci/test_suites/authenticators_oidc/test index b2508a5143..bea0f8bc48 100755 --- a/ci/test_suites/authenticators_oidc/test +++ b/ci/test_suites/authenticators_oidc/test @@ -36,7 +36,9 @@ function _hydrate_all_env_args() { } function main() { - docker-compose up --no-deps -d pg conjur keycloak + local parallel_services + read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" + docker-compose up --no-deps -d "${parallel_services[@]}" keycloak # We also run an ldap-server container for testing the OIDC & LDAP combined # use-case. We can't run this use-case in a separate Jenkins step because diff --git a/ci/test_suites/rotators/test b/ci/test_suites/rotators/test index 20b2634b81..badf62fa37 100755 --- a/ci/test_suites/rotators/test +++ b/ci/test_suites/rotators/test @@ -5,5 +5,14 @@ set -e # shellcheck disable=SC1091 source "./shared.sh" -additional_services='testdb' -_run_cucumber_tests rotators "$additional_services" +read -ra parallel_services <<< "$(get_parallel_services 'testdb')" +additional_services='' +for service in "${parallel_services[@]}"; do + if [[ "$additional_services" == '' ]]; then + additional_services+="${service}" + else + additional_services+=" ${service}" + fi +done + +_run_cucumber_tests rotators "${additional_services}" diff --git a/config/environments/development.rb b/config/environments/development.rb index c7a29d77cd..d3b9a48acb 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -13,7 +13,9 @@ # Whitelist conjur hostname for tests # For more information, refer to: # https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization - config.hosts << "conjur" + + # Accept multiple hosts for parallel tests + config.hosts << /^conjur[0-9]*$/ # eager_load needed to make authentication work without the hacky # loading code... diff --git a/config/environments/test.rb b/config/environments/test.rb index 26ea1a7b1d..db8a6d08dd 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -3,6 +3,17 @@ require 'logger/formatter/conjur_formatter' require 'test/audit_sink' +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. diff --git a/cucumber.yml b/cucumber.yml index 91a124072e..8482bacb1e 100644 --- a/cucumber.yml +++ b/cucumber.yml @@ -1,9 +1,11 @@ policy: > + --tags @policy --format pretty -r cucumber/policy cucumber/policy api: > + --tags @api --format pretty --tags "not @skip" -r cucumber/api/features/support/logs_helpers.rb @@ -21,6 +23,7 @@ api: > # directory is above it (e.g authenticators_azure is above authenticators_common) # then we will not be able to load the methods defined in authenticators_common authenticators_config: > + --tags @authenticators_config --format pretty -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb @@ -35,6 +38,7 @@ authenticators_config: > cucumber/authenticators_config authenticators_status: > + --tags @authenticators_status --format pretty -r cucumber/api/features/support/rest_helpers.rb -r cucumber/api/features/support/step_def_transforms.rb @@ -51,6 +55,7 @@ authenticators_status: > cucumber/authenticators_status authenticators_ldap: > + --tags @authenticators_ldap --format pretty -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb @@ -67,6 +72,7 @@ authenticators_ldap: > cucumber/authenticators_ldap authenticators_oidc: > + --tags @authenticators_oidc --format pretty -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb @@ -88,6 +94,7 @@ authenticators_oidc: > cucumber/authenticators_oidc authenticators_gcp: > + --tags @authenticators_gcp --format pretty -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb @@ -106,6 +113,7 @@ authenticators_gcp: > cucumber/authenticators_gcp authenticators_azure: > + --tags @authenticators_azure --format pretty -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb @@ -124,6 +132,7 @@ authenticators_azure: > cucumber/authenticators_azure authenticators_jwt: > + --tags "not @skip and @authenticators_jwt" --format pretty --tags "not @skip" -r cucumber/api/features/step_definitions/user_steps.rb @@ -149,9 +158,9 @@ authenticators_jwt: > # profiles need to be thought through better and refactored most likely. # rotators: > + --tags 'not @manual and @rotators' --format pretty --tags "not @skip" - -t 'not @manual' -r cucumber/authenticators/features/support/hooks.rb -r cucumber/api/features/support/step_def_transforms.rb -r cucumber/api/features/support/rest_helpers.rb @@ -167,8 +176,8 @@ rotators: > cucumber/rotators manual-rotators: > - --format pretty --tags @manual + --format pretty -r cucumber/rotators/features/support -r cucumber/rotators/features/step_definitions cucumber/rotators diff --git a/cucumber/_authenticators_common/features/support/authenticator_helpers.rb b/cucumber/_authenticators_common/features/support/authenticator_helpers.rb index e460bffa12..986c633bcc 100644 --- a/cucumber/_authenticators_common/features/support/authenticator_helpers.rb +++ b/cucumber/_authenticators_common/features/support/authenticator_helpers.rb @@ -104,7 +104,7 @@ def execute(method, path, payload = {}, options = {}) end def conjur_hostname - ENV.fetch('CONJUR_APPLIANCE_URL', 'http://conjur') + ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") end private diff --git a/cucumber/_authenticators_common/features/support/env.rb b/cucumber/_authenticators_common/features/support/env.rb index ccaeaf8735..35f864e9f1 100644 --- a/cucumber/_authenticators_common/features/support/env.rb +++ b/cucumber/_authenticators_common/features/support/env.rb @@ -1,5 +1,16 @@ # frozen_string_literal: true +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + $LOAD_PATH.unshift(Dir.pwd) require 'config/environment' @@ -9,5 +20,5 @@ require 'conjur-api' require 'json_spec/cucumber' -Conjur.configuration.appliance_url = ENV['CONJUR_APPLIANCE_URL'] || 'http://conjur' +Conjur.configuration.appliance_url = ENV['CONJUR_APPLIANCE_URL'] || "http://conjur#{ENV['TEST_ENV_NUMBER']}" Conjur.configuration.account = ENV['CONJUR_ACCOUNT'] || 'cucumber' diff --git a/cucumber/_authenticators_common/features/support/hooks.rb b/cucumber/_authenticators_common/features/support/hooks.rb index 80e4e0ba86..7da7be1ebf 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -10,6 +10,15 @@ # Prior to this hook, our tests had hidden coupling. This ensures each test is # run independently. Before do + parallel_cuke_vars = {} + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" + parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + + parallel_cuke_vars.each do |key, value| + ENV[key] = value + end + @user_index = 0 @host_index = 0 diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index 5ee5706374..e802ff8418 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -4,6 +4,17 @@ ENV['RAILS_ENV'] ||= 'test' ENV['CONJUR_LOG_LEVEL'] ||= 'debug' +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + # so that we can require relative to the project root $LOAD_PATH.unshift(File.expand_path('../../../..', __dir__)) require 'config/environment' diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index 13e05ec8ca..c931dd46a4 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -11,6 +11,15 @@ # Prior to this hook, our tests had hidden coupling. This ensures each test is # run independently. Before do + parallel_cuke_vars = {} + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" + parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + + parallel_cuke_vars.each do |key, value| + ENV[key] = value + end + @user_index = 0 @host_index = 0 diff --git a/cucumber/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index 3a3c27e928..4d29131b5d 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -7,6 +7,15 @@ require 'cucumber/_common/slosilo_helper' Before do + parallel_cuke_vars = {} + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" + parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + + parallel_cuke_vars.each do |key, value| + ENV[key] = value + end + @user_index = 0 Role.truncate(cascade: true) diff --git a/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature b/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature index a8c523707f..cb422b2bd4 100644 --- a/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature +++ b/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature @@ -875,32 +875,32 @@ Feature: JWT Authenticator - Token Schema - !variable conjur/authn-jwt/raw/enforced-claims - !host - id: myapp + id: myapp-01 annotations: authn-jwt/raw/conjur.org/enforced-property: valid - !grant role: !group conjur/authn-jwt/raw/hosts - member: !host myapp + member: !host myapp-01 """ And I successfully set authn-jwt "enforced-claims" variable to value "conjur.org/enforced-property" And I successfully set authn-jwt "token-app-property" variable to value "conjur.org/host-property" And I add the secret value "test-secret" to the resource "cucumber:variable:test-variable" - And I permit host "myapp" to "execute" it + And I permit host "myapp-01" to "execute" it And I am using file "authn-jwt-token-schema" and alg "RS256" for remotely issue token: """ { "conjur.org/enforced-property":"valid", - "conjur.org/host-property":"myapp" + "conjur.org/host-property":"myapp-01" } """ And I save my place in the log file When I authenticate via authn-jwt with the JWT token - Then host "myapp" has been authorized by Conjur + Then host "myapp-01" has been authorized by Conjur And I successfully GET "/secrets/cucumber/variable/test-variable" with authorized user And The following appears in the log after my savepoint: """ - cucumber:host:myapp successfully authenticated with authenticator authn-jwt service cucumber:webservice:conjur/authn-jwt/raw + cucumber:host:myapp-01 successfully authenticated with authenticator authn-jwt service cucumber:webservice:conjur/authn-jwt/raw """ @sanity diff --git a/cucumber/authenticators_k8s/features/support/env.rb b/cucumber/authenticators_k8s/features/support/env.rb index ee654726b9..4bcebd1693 100644 --- a/cucumber/authenticators_k8s/features/support/env.rb +++ b/cucumber/authenticators_k8s/features/support/env.rb @@ -2,6 +2,17 @@ require 'rack/test' require 'json_spec/cucumber' +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://nginx#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@postgres#{ENV['TEST_ENV_NUMBER']}:5432/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + def app Rails.application end diff --git a/cucumber/authenticators_k8s/features/support/hooks.rb b/cucumber/authenticators_k8s/features/support/hooks.rb index c4fa9b56bd..b8df4fbcda 100644 --- a/cucumber/authenticators_k8s/features/support/hooks.rb +++ b/cucumber/authenticators_k8s/features/support/hooks.rb @@ -7,6 +7,15 @@ end Before do + parallel_cuke_vars = {} + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "https://nginx#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@postgres#{ENV['TEST_ENV_NUMBER']}:5432/postgres" + parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + + parallel_cuke_vars.each do |key, value| + ENV[key] = value + end + # Erase the certificate and cert injection logs from each container. kube_client.get_pods(namespace: namespace).select{|p| p.metadata.namespace == namespace}.each do |pod| next unless (ready_status = pod.status.conditions.find { |c| c.type == "Ready" }) diff --git a/cucumber/policy/features/support/client.rb b/cucumber/policy/features/support/client.rb index 3f4af64e13..c8e0b76d80 100644 --- a/cucumber/policy/features/support/client.rb +++ b/cucumber/policy/features/support/client.rb @@ -13,7 +13,7 @@ class Client ADMIN_PASSWORD = 'SEcret12!!!!' ACCOUNT = ENV['CONJUR_ACCOUNT'] || 'cucumber' - APPLIANCE_URL = ENV['CONJUR_APPLIANCE_URL'] || 'http://conjur' + APPLIANCE_URL = ENV['CONJUR_APPLIANCE_URL'] || "http://conjur#{ENV['TEST_ENV_NUMBER']}" class User def initialize(user_type, id) diff --git a/cucumber/policy/features/support/env.rb b/cucumber/policy/features/support/env.rb index 0a9bf2c17d..179752d970 100644 --- a/cucumber/policy/features/support/env.rb +++ b/cucumber/policy/features/support/env.rb @@ -5,7 +5,18 @@ require 'conjur-api' require 'rest-client' -Conjur.configuration.appliance_url = ENV['CONJUR_APPLIANCE_URL'] || 'http://conjur' +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + +Conjur.configuration.appliance_url = ENV['CONJUR_APPLIANCE_URL'] || "http://conjur#{ENV['TEST_ENV_NUMBER']}" Conjur.configuration.account = ENV['CONJUR_ACCOUNT'] || 'cucumber' # This is needed to run the cucumber --profile policy successfully diff --git a/cucumber/policy/features/support/hooks.rb b/cucumber/policy/features/support/hooks.rb index efa7add043..f550843861 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -19,6 +19,15 @@ # Prior to this hook, our tests had hidden coupling. This ensures each test is # run independently. Before do + parallel_cuke_vars = {} + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" + parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + + parallel_cuke_vars.each do |key, value| + ENV[key] = value + end + @user_index = 0 Role.truncate(cascade: true) diff --git a/cucumber/rotators/features/step_definitions/rotator_steps.rb b/cucumber/rotators/features/step_definitions/rotator_steps.rb index a65255e7ab..307e3a19cb 100644 --- a/cucumber/rotators/features/step_definitions/rotator_steps.rb +++ b/cucumber/rotators/features/step_definitions/rotator_steps.rb @@ -69,6 +69,9 @@ end Given(/^I add the value "(.*)" to variable "(.+)"$/) do |val, id| + if val == "testdb" + val = "#{val}#{ENV['TEST_ENV_NUMBER']}" + end @client.add_secret(id: id, value: val) end diff --git a/cucumber/rotators/features/support/env.rb b/cucumber/rotators/features/support/env.rb index 2cdb385df7..74ec8c5016 100644 --- a/cucumber/rotators/features/support/env.rb +++ b/cucumber/rotators/features/support/env.rb @@ -1,5 +1,16 @@ # frozen_string_literal: true +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + # so that we can require relative to the project root $LOAD_PATH.unshift(Dir.pwd) require 'config/environment' @@ -7,5 +18,5 @@ ENV['RAILS_ENV'] ||= 'test' ENV['CONJUR_LOG_LEVEL'] ||= 'debug' -Conjur.configuration.appliance_url = ENV['CONJUR_APPLIANCE_URL'] || 'http://conjur' +Conjur.configuration.appliance_url = ENV['CONJUR_APPLIANCE_URL'] || "http://conjur#{ENV['TEST_ENV_NUMBER']}" Conjur.configuration.account = ENV['CONJUR_ACCOUNT'] || 'cucumber' diff --git a/cucumber/rotators/features/support/rotator_helpers.rb b/cucumber/rotators/features/support/rotator_helpers.rb index 832961acca..184cb7b67c 100644 --- a/cucumber/rotators/features/support/rotator_helpers.rb +++ b/cucumber/rotators/features/support/rotator_helpers.rb @@ -11,7 +11,7 @@ module RotatorHelpers # Utility for the postgres rotator def run_sql_in_testdb(sql, user="postgres", pw="postgres_secret") - system("PGPASSWORD=#{pw} psql -h testdb -U #{user} -c \"#{sql}\"") + system("PGPASSWORD=#{pw} psql -h testdb#{ENV['TEST_ENV_NUMBER']} -U #{user} -c \"#{sql}\"") end def variable(id) @@ -75,7 +75,7 @@ def current_value # is hardcoded here. This shouldn't be problematic as there's likely no # need to make it dynamic. def pg_login_result(user, pw) - system("PGPASSWORD=#{pw} psql -c \"\\q\" -h testdb -U #{user}") + system("PGPASSWORD=#{pw} psql -c \"\\q\" -h testdb#{ENV['TEST_ENV_NUMBER']} -U #{user}") end end diff --git a/engines/conjur_audit/config/routes.rb b/engines/conjur_audit/config/routes.rb index 76ba647776..70e6a08710 100644 --- a/engines/conjur_audit/config/routes.rb +++ b/engines/conjur_audit/config/routes.rb @@ -1,5 +1,16 @@ # frozen_string_literal: true +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + ConjurAudit::Engine.routes.draw do scope format: false do root 'messages#index' diff --git a/engines/conjur_audit/spec/dummy/config/environments/test.rb b/engines/conjur_audit/spec/dummy/config/environments/test.rb index 08941f0fff..d94bba7023 100644 --- a/engines/conjur_audit/spec/dummy/config/environments/test.rb +++ b/engines/conjur_audit/spec/dummy/config/environments/test.rb @@ -1,5 +1,16 @@ # frozen_string_literal: true +parallel_cuke_vars = {} +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" +parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + +parallel_cuke_vars.each do |key, value| + if ENV[key].nil? || ENV[key].empty? + ENV[key] = value + end +end + Rails.application.configure do config.cache_classes = true config.eager_load = false From 922ac8126ea811bc29a43950dc5dbb1ca5e99141 Mon Sep 17 00:00:00 2001 From: Isaiah Peralta Date: Thu, 15 Jun 2023 17:56:18 -0400 Subject: [PATCH 136/665] Change from docker-compose to docker compose in scripts (cherry picked from commit ace50976cf33119e8f63beb924972bcb42e0fe17) --- UPGRADING.md | 14 +++---- ci/oauth/keycloak/keycloak_functions.sh | 12 +++--- ci/shared.sh | 28 ++++++------- ci/test | 2 +- ci/test_suites/authenticators_jwt/test | 4 +- ci/test_suites/authenticators_oidc/test | 4 +- ci/test_suites/rspec/test | 4 +- ci/test_suites/rspec_audit/test | 4 +- dev/cli | 10 ++--- dev/start | 54 ++++++++++++------------- dev/stop | 2 +- 11 files changed, 69 insertions(+), 69 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 2a01625170..061e7fe796 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -19,29 +19,29 @@ that was used when you originally deployed your Conjur server. 3. Pull the new Conjur image version: ``` - docker-compose pull conjur + docker compose pull conjur ``` 4. Stop the Conjur container: ``` - docker-compose stop conjur + docker compose stop conjur ``` 5. Bring up the Conjur service using the new image version without changing linked services: ``` - docker-compose up -d --no-deps conjur + docker compose up -d --no-deps conjur ``` 6. View Docker containers and verify all are healthy, up and running: ``` - docker-compose ps -a + docker compose ps -a ``` It may also be useful to check if Conjur started successfully, which can be done by running ``` - $ docker-compose exec conjur conjurctl wait + $ docker compose exec conjur conjurctl wait Waiting for Conjur to be ready... ... Conjur is ready! @@ -56,7 +56,7 @@ environment variable first, you will be able to complete the steps without an visible/explicit error message, but the logs of the new Conjur container will show an error like: ``` -$ docker-compose logs conjur_server +$ docker compose logs conjur_server rake aborted! No CONJUR_DATA_KEY ... @@ -66,7 +66,7 @@ To fix this, set the `CONJUR_DATA_KEY` environment variable and run through the [process](#standard-process) again. This time when you check the logs of the Conjur server container you should see the service starting as expected: ``` -$ docker-compose logs conjur_server +$ docker compose logs conjur_server ... => Booting Puma => Rails 5.2.4.3 application starting in production diff --git a/ci/oauth/keycloak/keycloak_functions.sh b/ci/oauth/keycloak/keycloak_functions.sh index dada055816..845c28f0e3 100644 --- a/ci/oauth/keycloak/keycloak_functions.sh +++ b/ci/oauth/keycloak/keycloak_functions.sh @@ -16,7 +16,7 @@ function _hydrate_keycloak_env_args() { set -o pipefail # Note: This prints all lines that look like: # KEYCLOAK_XXX=someval - docker-compose exec -T ${KEYCLOAK_SERVICE_NAME} printenv | awk '/KEYCLOAK/' + docker compose exec -T ${KEYCLOAK_SERVICE_NAME} printenv | awk '/KEYCLOAK/' ) # shellcheck disable=SC2034 @@ -34,14 +34,14 @@ function _hydrate_keycloak_env_args() { # _create_keycloak_user '$APP_USER' '$APP_PW' '$APP_EMAIL' # # This is because those variables are not available to this script. They are -# available to bash commands run via "docker-compose exec keycloak bash +# available to bash commands run via "docker compose exec keycloak bash # -c...", since they're defined in the docker-compose.yml. function _create_keycloak_user() { local user_var=$1 local pw_var=$2 local email_var=$3 - docker-compose exec -T \ + docker compose exec -T \ ${KEYCLOAK_SERVICE_NAME} \ bash -c "/scripts/create_user \"$user_var\" \"$pw_var\" \"$email_var\"" } @@ -49,7 +49,7 @@ function _create_keycloak_user() { function create_keycloak_users() { echo "Defining keycloak client" - docker-compose exec -T ${KEYCLOAK_SERVICE_NAME} /scripts/create_client + docker compose exec -T ${KEYCLOAK_SERVICE_NAME} /scripts/create_client echo "Creating user 'alice' in Keycloak" @@ -80,7 +80,7 @@ function create_keycloak_users() { } function wait_for_keycloak_server() { - docker-compose exec -T \ + docker compose exec -T \ ${KEYCLOAK_SERVICE_NAME} /scripts/wait_for_server } @@ -93,7 +93,7 @@ function fetch_keycloak_certificate() { read -ra parallel_services <<< "$(get_parallel_services 'conjur')" for parallel_service in "${parallel_services[@]}"; do - docker-compose exec -T \ + docker compose exec -T \ "${parallel_service}" /oauth/keycloak/scripts/fetch_certificate done } diff --git a/ci/shared.sh b/ci/shared.sh index 537c831613..21b9fc8dfe 100644 --- a/ci/shared.sh +++ b/ci/shared.sh @@ -66,20 +66,20 @@ _run_cucumber_tests() { read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" if (( ${#services[@]} )); then - docker-compose up --no-deps --no-recreate -d "${parallel_services[@]}" "${services[@]}" + docker compose up --no-deps --no-recreate -d "${parallel_services[@]}" "${services[@]}" else - docker-compose up --no-deps --no-recreate -d "${parallel_services[@]}" + docker compose up --no-deps --no-recreate -d "${parallel_services[@]}" fi read -ra parallel_services <<< "$(get_parallel_services 'conjur')" for parallel_service in "${parallel_services[@]}"; do - docker-compose exec -T "$parallel_service" conjurctl wait --retries 180 + docker compose exec -T "$parallel_service" conjurctl wait --retries 180 done echo "Create cucumber account..." for parallel_service in "${parallel_services[@]}"; do - docker-compose exec -T "$parallel_service" conjurctl account create cucumber + docker compose exec -T "$parallel_service" conjurctl account create cucumber done # Stage 2: Prepare cucumber environment args @@ -113,8 +113,8 @@ _run_cucumber_tests() { done # Add the cucumber env vars that we always want to send. - # Note: These are args for docker-compose run, and as such the right hand - # sides of the = do NOT require escaped quotes. docker-compose takes the + # Note: These are args for docker compose run, and as such the right hand + # sides of the = do NOT require escaped quotes. docker compose takes the # entire arg, splits on the =, and uses the rhs as the value, env_var_flags+=( -e "CUCUMBER_NETWORK=$(_find_cucumber_network)" @@ -127,7 +127,7 @@ _run_cucumber_tests() { done # If there's no tty (e.g. we're running as a Jenkins job), pass -T to - # docker-compose. + # docker compose. run_flags=(--no-deps --rm) if ! tty -s; then run_flags+=(-T) @@ -153,7 +153,7 @@ _run_cucumber_tests() { # Have to add tags in profile for parallel to run properly # ${cucumber_tags_arg} should overwrite the profile tags in a way for @smoke to work correctly - docker-compose run "${run_flags[@]}" "${env_var_flags[@]}" \ + docker compose run "${run_flags[@]}" "${env_var_flags[@]}" \ cucumber -ec "\ /oauth/keycloak/scripts/fetch_certificate && bundle exec parallel_cucumber . -n ${PARALLEL_PROCESSES} \ @@ -170,14 +170,14 @@ _run_cucumber_tests() { # process to write the report. The container is kept alive using an infinite # sleep in the at_exit hook (see .simplecov). for parallel_service in "${parallel_services[@]}"; do - docker-compose exec -T "$parallel_service" bash -c "pkill -f 'puma 5'" + docker compose exec -T "$parallel_service" bash -c "pkill -f 'puma 5'" done } _get_api_key() { local service=$1 - docker-compose exec -T "${service}" conjurctl \ + docker compose exec -T "${service}" conjurctl \ role retrieve-key cucumber:user:admin | tr -d '\r' } @@ -187,7 +187,7 @@ _find_cucumber_network() { # Docker compose conjur/pg services use the same # network for 1 or more instances so only conjur is passed # and not other parallel services. - conjur_id=$(docker-compose ps -q conjur) + conjur_id=$(docker compose ps -q conjur) net=$(docker inspect "${conjur_id}" --format '{{.HostConfig.NetworkMode}}') docker network inspect "$net" \ @@ -218,7 +218,7 @@ wait_for_cmd() { _wait_for_pg() { local svc=$1 local pg_cmd=(psql -U postgres -c "select 1" -d postgres) - local dc_cmd=(docker-compose exec -T "$svc" "${pg_cmd[@]}") + local dc_cmd=(docker compose exec -T "$svc" "${pg_cmd[@]}") echo "Waiting for pg to come up..." @@ -237,14 +237,14 @@ is_ldap_up() { # Note: We need the subshell to group the commands. ( set -o pipefail - docker-compose exec -T ldap-server bash -c "$ldap_check_cmd" | + docker compose exec -T ldap-server bash -c "$ldap_check_cmd" | grep '^search: 3$' ) >/dev/null 2>&1 } start_ldap_server() { # Start LDAP. - docker-compose up --no-deps --detach ldap-server + docker compose up --no-deps --detach ldap-server # Wait for up to 90 seconds, since it's slow. echo "Ensuring that LDAP is up..." diff --git a/ci/test b/ci/test index 0ba14cbabb..c48f2fa451 100755 --- a/ci/test +++ b/ci/test @@ -117,7 +117,7 @@ finish() { # TODO: More reliable approach to this. # Give SimpleCov time to generate reports. sleep 15 - docker-compose down --rmi 'local' --volumes || true + docker compose down --rmi 'local' --volumes || true } # main is always called with at least the first arg. When the 2nd arg, the diff --git a/ci/test_suites/authenticators_jwt/test b/ci/test_suites/authenticators_jwt/test index db1a27a949..e76371bf10 100755 --- a/ci/test_suites/authenticators_jwt/test +++ b/ci/test_suites/authenticators_jwt/test @@ -10,14 +10,14 @@ source "./oauth/keycloak/keycloak_functions.sh" function main() { local parallel_services read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" - docker-compose up --no-deps -d "${parallel_services[@]}" jwks jwks_py keycloak + docker compose up --no-deps -d "${parallel_services[@]}" jwks jwks_py keycloak wait_for_keycloak_server create_keycloak_users fetch_keycloak_certificate echo "Configure jwks provider" - docker-compose exec -T jwks "${JWKS_CREATE_CERTIFICATE_SCRIPT_PATH}" + docker compose exec -T jwks "${JWKS_CREATE_CERTIFICATE_SCRIPT_PATH}" additional_services='jwks jwks_py keycloak' _run_cucumber_tests authenticators_jwt "$additional_services" \ diff --git a/ci/test_suites/authenticators_oidc/test b/ci/test_suites/authenticators_oidc/test index bea0f8bc48..85823c900c 100755 --- a/ci/test_suites/authenticators_oidc/test +++ b/ci/test_suites/authenticators_oidc/test @@ -17,7 +17,7 @@ function _hydrate_all_env_args() { set -o pipefail # Note: This prints all lines that look like: # KEYCLOAK_XXX=someval - docker-compose exec -T "${KEYCLOAK_SERVICE_NAME}" printenv | awk '/KEYCLOAK/' + docker compose exec -T "${KEYCLOAK_SERVICE_NAME}" printenv | awk '/KEYCLOAK/' ) # shellcheck disable=SC2034 @@ -38,7 +38,7 @@ function _hydrate_all_env_args() { function main() { local parallel_services read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" - docker-compose up --no-deps -d "${parallel_services[@]}" keycloak + docker compose up --no-deps -d "${parallel_services[@]}" keycloak # We also run an ldap-server container for testing the OIDC & LDAP combined # use-case. We can't run this use-case in a separate Jenkins step because diff --git a/ci/test_suites/rspec/test b/ci/test_suites/rspec/test index d2eb5f3eaf..49eee55cea 100755 --- a/ci/test_suites/rspec/test +++ b/ci/test_suites/rspec/test @@ -6,13 +6,13 @@ set -e # shellcheck disable=SC1091 source "./shared.sh" -docker-compose up --no-deps -d pg +docker compose up --no-deps -d pg _wait_for_pg pg # Note: The nested, escaped double quotes are needed in case $REPORT_ROOT # ever changes to a path containing a space. -docker-compose run -T --rm --no-deps cucumber -ec " +docker compose run -T --rm --no-deps cucumber -ec " bundle exec rake db:migrate rm -rf \"$REPORT_ROOT/spec/reports\" diff --git a/ci/test_suites/rspec_audit/test b/ci/test_suites/rspec_audit/test index f68d9a71a3..b6ec77c689 100755 --- a/ci/test_suites/rspec_audit/test +++ b/ci/test_suites/rspec_audit/test @@ -7,7 +7,7 @@ set -e source "./shared.sh" # Start Conjur with the audit database -docker-compose up --no-deps -d audit pg +docker compose up --no-deps -d audit pg _wait_for_pg audit @@ -15,7 +15,7 @@ _wait_for_pg audit # $REPORT_ROOT but not for the 2nd one where it appears in the variable # assignment. AUDIT_DATABASE_URL=postgres://postgres@audit/postgres \ - docker-compose run \ + docker compose run \ -T --rm --no-deps --workdir=/src/conjur-server cucumber -ec " pwd ci/rspec-audit/migratedb diff --git a/dev/cli b/dev/cli index d86573a56e..3275332ccb 100755 --- a/dev/cli +++ b/dev/cli @@ -196,7 +196,7 @@ function add_keycloak_env_vars_to_env_args() { echo "Extracting keycloak variables & setting as env variables" local keycloak_env_args='' - keycloak_env_args="$(set -o pipefail; docker-compose exec -T keycloak printenv | grep KEYCLOAK | sed 's/.*/-e &/') \ + keycloak_env_args="$(set -o pipefail; docker compose exec -T keycloak printenv | grep KEYCLOAK | sed 's/.*/-e &/') \ -e PROVIDER_URI=https://keycloak:8443/auth/realms/master \ -e PROVIDER_INTERNAL_URI=http://keycloak:8080/auth/realms/master/protocol/openid-connect \ -e PROVIDER_ISSUER=http://keycloak:8080/auth/realms/master \ @@ -234,7 +234,7 @@ while true ; do case "$1" in -h | --help ) print_help ; shift ;; exec ) - api_key=$(docker-compose exec -T conjur conjurctl role retrieve-key cucumber:user:admin | tr -d '\r') + api_key=$(docker compose exec -T conjur conjurctl role retrieve-key cucumber:user:admin | tr -d '\r') env_args="-e CONJUR_AUTHN_API_KEY=$api_key" case "$2" in @@ -246,18 +246,18 @@ while true ; do * ) if [ -z "$2" ]; then shift ; else echo "$2 is not a valid option"; exit 1; fi;; esac - docker exec $env_args -it --detach-keys 'ctrl-\' $(docker-compose ps -q conjur) bash + docker exec "$env_args" -it --detach-keys "ctrl-\'" "$(docker compose ps -q conjur)" bash shift ;; policy ) case "$2" in load ) account="$3" policy_file=$4 - docker-compose exec conjur conjurctl policy load "$account" "/src/conjur-server/$policy_file" + docker compose exec conjur conjurctl policy load "$account" "/src/conjur-server/$policy_file" shift 4 ;; * ) if [ -z "$1" ]; then break; else echo "$1 is not a valid option"; exit 1; fi;; esac ;; - key ) docker-compose exec -T conjur conjurctl role retrieve-key cucumber:user:admin ; shift ;; + key ) docker compose exec -T conjur conjurctl role retrieve-key cucumber:user:admin ; shift ;; * ) if [ -z "$1" ]; then break; else echo "$1 is not a valid option"; exit 1; fi;; esac done diff --git a/dev/start b/dev/start index 71900e2f93..adc1e9257c 100755 --- a/dev/start +++ b/dev/start @@ -57,16 +57,16 @@ main() { fi # Build docker images. - docker-compose build --pull + docker compose build --pull init_data_key init_audit_service # Install gems, create DB, and create cucumber account. - docker-compose up -d --no-deps "${services[@]}" - docker-compose exec conjur bundle - docker-compose exec conjur conjurctl db migrate - docker-compose exec conjur conjurctl account create cucumber || true + docker compose up -d --no-deps "${services[@]}" + docker compose exec conjur bundle + docker compose exec conjur conjurctl db migrate + docker compose exec conjur conjurctl account create cucumber || true migrate_audit_db @@ -182,7 +182,7 @@ check_env_vars() { client_load_policy() { local policy_file=$1 - docker-compose exec client \ + docker compose exec client \ conjur policy load root "$policy_file" } @@ -190,13 +190,13 @@ client_add_secret() { local variable=$1 local value=$2 - docker-compose exec client \ + docker compose exec client \ conjur variable values add "$variable" "$value" } start_conjur_server() { echo "Starting Conjur server" - docker-compose exec -d conjur conjurctl server + docker compose exec -d conjur conjurctl server echo 'Checking if Conjur server is ready' wait_for_conjur @@ -204,13 +204,13 @@ start_conjur_server() { wait_for_conjur() { api_key=$( - docker-compose exec -T conjur \ + docker compose exec -T conjur \ conjurctl role retrieve-key cucumber:user:admin | tr -d '\r' ) - docker-compose exec -T conjur conjurctl wait + docker compose exec -T conjur conjurctl wait - docker-compose exec client conjur authn login -u admin -p "$api_key" + docker compose exec client conjur authn login -u admin -p "$api_key" } configure_oidc_authenticators() { @@ -232,11 +232,11 @@ configure_oidc_authenticators() { } setup_keycloak() { - # Start keycloak docker-compose service + # Start keycloak docker compose service services+=(keycloak) - docker-compose up -d --no-deps "${services[@]}" + docker compose up -d --no-deps "${services[@]}" - # Start conjur again, since it is recreating by docker-compose because of + # Start conjur again, since it is recreating by docker compose because of # dependency with keycloak start_conjur_server wait_for_keycloak_server @@ -266,7 +266,7 @@ setup_okta() { setup_adfs() { echo "Initialize ADFS certificate in conjur server" - docker-compose exec conjur \ + docker compose exec conjur \ /src/conjur-server/dev/files/authn-oidc/adfs/fetchCertificate configure_oidc_v1 \ @@ -361,10 +361,10 @@ migrate_audit_db() { # Run database migration to create audit database schema. # - # SC2016: We want docker-compose to expand $AUDIT_DATABASE_URL. + # SC2016: We want docker compose to expand $AUDIT_DATABASE_URL. # SC1004: Literal backslash will be interpreted away by bash. # shellcheck disable=SC2016,SC1004 - docker-compose exec -T conjur bash -c ' + docker compose exec -T conjur bash -c ' BUNDLE_GEMFILE=/src/conjur-server/Gemfile \ bundle exec sequel $AUDIT_DATABASE_URL \ -E -m /src/conjur-server/engines/conjur_audit/db/migrate/ @@ -386,7 +386,7 @@ init_ldap() { enabled_authenticators="$enabled_authenticators,authn-ldap/test,authn-ldap/secure" # Using conjur policy load doesn't work here (not sure why). - docker-compose exec conjur \ + docker compose exec conjur \ conjurctl policy load cucumber \ "/src/conjur-server/dev/files/authn-ldap/policy.yml" } @@ -403,7 +403,7 @@ init_azure() { client_load_policy \ "/src/conjur-server/ci/test_suites/authenticators_azure/policies/policy.yml" - docker-compose exec client \ + docker compose exec client \ conjur variable values add \ conjur/authn-azure/prod/provider-uri "https://sts.windows.net/$AZURE_TENANT_ID/" @@ -444,7 +444,7 @@ init_jwt() { enabled_authenticators="$enabled_authenticators,authn-jwt/raw,authn-jwt/keycloak" services+=(jwks jwks_py keycloak) - docker-compose up -d --no-deps "${services[@]}" + docker compose up -d --no-deps "${services[@]}" # OIDC is a special case on JWT, JWT automation tests contain scenarios with # OIDC providers. @@ -452,7 +452,7 @@ init_jwt() { enable_oidc_authenticators echo "Configure jwks provider" - docker-compose exec jwks "/tmp/create_nginx_certificate.sh" + docker compose exec jwks "/tmp/create_nginx_certificate.sh" } init_oidc() { @@ -466,10 +466,10 @@ init_oidc() { if [[ $ENABLE_AUTHN_LDAP = true && $ENABLE_OIDC_OKTA = true ]]; then echo "Building & configuring Okta-LDAP agent" services+=(okta-ldap-agent) - docker-compose up -d --no-deps "${services[@]}" + docker compose up -d --no-deps "${services[@]}" echo "Starting Okta agent service" - docker exec "$(docker-compose ps -q okta-ldap-agent)" \ + docker exec "$(docker compose ps -q okta-ldap-agent)" \ "/opt/Okta/OktaLDAPAgent/scripts/OktaLDAPAgent" fi } @@ -489,7 +489,7 @@ init_iam() { enabled_authenticators="$enabled_authenticators,authn-iam/prod" # Using conjur policy load doesn't work here (not sure why). - docker-compose exec conjur \ + docker compose exec conjur \ conjurctl policy load cucumber \ "/src/conjur-server/dev/files/authn-iam/policy.yml" } @@ -500,7 +500,7 @@ start_auth_services() { # Will restart services if configuration has changed. I think this happens # when additional authenticators are enabled. - docker-compose up -d --no-deps "${services[@]}" + docker compose up -d --no-deps "${services[@]}" # If that happens, we need to restart Conjur server. if [[ "$enabled_authenticators" != "$default_authenticators" ]]; then @@ -515,13 +515,13 @@ create_alice() { kill_conjur() { echo "killing the conjur server process" - docker-compose exec conjur /src/conjur-server/dev/files/killConjurServer + docker compose exec conjur /src/conjur-server/dev/files/killConjurServer } enter_container() { env_args+=(-e "CONJUR_AUTHN_API_KEY=$api_key") docker exec "${env_args[@]}" -it --detach-keys ctrl-\\ \ - "$(docker-compose ps -q conjur)" bash + "$(docker compose ps -q conjur)" bash } main "$@" diff --git a/dev/stop b/dev/stop index abc3fa6946..16f9130c56 100755 --- a/dev/stop +++ b/dev/stop @@ -2,6 +2,6 @@ unset COMPOSE_PROJECT_NAME -docker-compose down -v +docker compose down -v rm -f data_key From 37b280f82f3f381c15eba2e48e650f845b4e9bac Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Mon, 26 Jun 2023 18:17:08 +0300 Subject: [PATCH 137/665] Fix changelog for 1.19.5 release (cherry picked from commit d6b222490e59e32b0dae156176657847b7a4f488) --- CHANGELOG.md | 7 +++---- Jenkinsfile | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fe7d3304..303648847d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,7 +132,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Remove auto-release options to allow for a pseudo-fork development on a branch -## [1.19.5] - 2023-05-16 +## [1.19.5] - 2023-05-29 ### Security - Update bundler to 2.2.33 to remove CVE-2021-43809 @@ -144,8 +144,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Authn-IAM now uses the host in the signed headers to determine which STS endpoint (global or regional) to use for validation. -## [1.19.4] - 2023-05-12 - ### Changed - OIDC tokens will now have a default ttl of 60 mins [cyberark/conjur#2800](https://github.com/cyberark/conjur/pull/2800) @@ -1168,7 +1166,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - The first tagged version. -[Unreleased]: https://github.com/cyberark/conjur/compare/v1.19.3...HEAD +[Unreleased]: https://github.com/cyberark/conjur/compare/v1.19.5...HEAD +[1.19.5]: https://github.com/cyberark/conjur/compare/v1.19.3...v1.19.5 [1.19.3]: https://github.com/cyberark/conjur/compare/v1.19.2...v1.19.3 [1.19.2]: https://github.com/cyberark/conjur/compare/v1.19.1...v1.19.2 [1.19.1]: https://github.com/cyberark/conjur/compare/v1.19.0...v1.19.1 diff --git a/Jenkinsfile b/Jenkinsfile index 98a13c2f80..7137466cb2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -820,11 +820,11 @@ pipeline { def addNewImagesToAgent() { // Pull and retag existing images onto new Jenkins agent sh """ - docker pull registry.tld/conjur:${tagWithSHA()} - docker pull registry.tld/conjur-ubi:${tagWithSHA()} + docker pull registry.tld/conjur-cloud:${tagWithSHA()} + docker pull registry.tld/conjur-ubi-cloud:${tagWithSHA()} docker pull registry.tld/conjur-test:${tagWithSHA()} - docker tag registry.tld/conjur:${tagWithSHA()} conjur:${tagWithSHA()} - docker tag registry.tld/conjur-ubi:${tagWithSHA()} conjur-ubi:${tagWithSHA()} + docker tag registry.tld/conjur-cloud:${tagWithSHA()} conjur:${tagWithSHA()} + docker tag registry.tld/conjur-ubi-cloud:${tagWithSHA()} conjur-ubi:${tagWithSHA()} docker tag registry.tld/conjur-test:${tagWithSHA()} conjur-test:${tagWithSHA()} """ } From e13ea6de71c4551d81551e41c6e7098c8782a6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hubert=20D=C4=85browski?= Date: Tue, 27 Jun 2023 12:05:37 +0300 Subject: [PATCH 138/665] Moved stash operation after tests to post statement (cherry picked from commit 0f4c0b0a503e63fb20de2b74bbf7146efb13c3d9) --- Jenkinsfile | 151 +++++++++++++++++++++++++++++----------------------- 1 file changed, 84 insertions(+), 67 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 7137466cb2..07f551eab9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -235,18 +235,21 @@ pipeline { params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0] ) - stash( - name: 'testResultEE', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - gems/conjur-rack/spec/reports/*.xml, - gems/slosilo/spec/reports/*.xml, + } + post { + always { + stash( + name: 'testResultEE', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml, cucumber/*/features/reports/**/*.xml ''' - ) + )} } } // Run a subset of tests on a second agent to prevent oversubscribing the hardware @@ -272,16 +275,20 @@ pipeline { params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1] ) - stash( - name: 'testResultEE2', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - cucumber/*/features/reports/**/*.xml - ''' - ) + } + post { + always { + stash( + name: 'testResultEE2', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } } } // Run a subset of tests on a second agent to prevent oversubscribing the hardware @@ -308,16 +315,20 @@ pipeline { params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2] ) - stash( - name: 'testResultEE3', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - cucumber/*/features/reports/**/*.xml - ''' - ) + } + post { + always { + stash( + name: 'testResultEE3', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } } } } @@ -424,16 +435,20 @@ pipeline { addNewImagesToAgent() unstash 'version_info' runConjurTests(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]) - stash( - name: 'standardTestResult2', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - cucumber/*/features/reports/**/*.xml - ''' - ) + } + post { + always { + stash( + name: 'standardTestResult2', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } } } @@ -460,17 +475,21 @@ pipeline { params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2] ) - stash( - name: 'standardTestResult3', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - cucumber/*/features/reports/**/*.xml, - ci/test_suites/*/output/* - ''' - ) + } + post { + always { + stash( + name: 'standardTestResult3', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml, + ci/test_suites/*/output/* + ''' + ) + } } } @@ -512,19 +531,19 @@ pipeline { post { always { - stash( - name: 'testResultAzure', - allowEmpty: true, - includes: ''' - cucumber/*azure*/*.*, - container_logs/*azure*/*, - cucumber_results*.json - ''' - ) - // Remove this Agent's IP from IPManager's prefix list - // There are a limited number of entries, so it remove it - // rather than waiting for it to expire. - removeIPAccess() + stash( + name: 'testResultAzure', + allowEmpty: true, + includes: ''' + cucumber/*azure*/*.*, + container_logs/*azure*/*, + cucumber_results*.json + ''' + ) + // Remove this Agent's IP from IPManager's prefix list + // There are a limited number of entries, so it remove it + // rather than waiting for it to expire. + removeIPAccess() } } } @@ -821,10 +840,8 @@ def addNewImagesToAgent() { // Pull and retag existing images onto new Jenkins agent sh """ docker pull registry.tld/conjur-cloud:${tagWithSHA()} - docker pull registry.tld/conjur-ubi-cloud:${tagWithSHA()} docker pull registry.tld/conjur-test:${tagWithSHA()} - docker tag registry.tld/conjur-cloud:${tagWithSHA()} conjur:${tagWithSHA()} - docker tag registry.tld/conjur-ubi-cloud:${tagWithSHA()} conjur-ubi:${tagWithSHA()} + docker tag registry.tld/conjur-cloud:${tagWithSHA()} conjur-cloud:${tagWithSHA()} docker tag registry.tld/conjur-test:${tagWithSHA()} conjur-test:${tagWithSHA()} """ } From f885ff27260d91f4b7e9369a2d8220a00d47606d Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 21 Jun 2023 00:50:55 +0300 Subject: [PATCH 139/665] Support extracting STS host region from authorization header (cherry picked from commit 4172260f799c49b51278e64c2723c2e4e7863432) --- CHANGELOG.md | 6 ++ .../authentication/authn_iam/authenticator.rb | 33 ++++++- app/domain/logs.rb | 5 + config/environments/development.rb | 2 +- dev/start | 13 ++- .../authn_iam/authenticator_spec.rb | 31 ++++++ .../valid-global-headers-no-host.yml | 95 +++++++++++++++++++ .../valid-regional-headers-no-host.yml | 50 ++++++++++ 8 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers-no-host.yml create mode 100644 spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers-no-host.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 303648847d..11436f97d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Remove auto-release options to allow for a pseudo-fork development on a branch +## [1.19.6] - 2023-07-05 + +### Fixed +- Support Authn-IAM regional requests when host value is missing from signed headers. + [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) + ## [1.19.5] - 2023-05-29 ### Security diff --git a/app/domain/authentication/authn_iam/authenticator.rb b/app/domain/authentication/authn_iam/authenticator.rb index 5fab2f3c89..cfef517adb 100755 --- a/app/domain/authentication/authn_iam/authenticator.rb +++ b/app/domain/authentication/authn_iam/authenticator.rb @@ -54,7 +54,27 @@ def extract_relevant_data(response) # Call to AWS STS endpoint using the provided authentication header def attempt_signed_request(signed_headers) - aws_request = URI("https://#{signed_headers['host']}/?Action=GetCallerIdentity&Version=2011-06-15") + sts_host = extract_sts_host(signed_headers) + aws_request = URI("https://#{sts_host}/?Action=GetCallerIdentity&Version=2011-06-15") + begin + response = @client.get_response(aws_request, signed_headers) + return response unless response.code.to_i == 403 && sts_host.include?('us-east-1') + + # If the request to `us-east-1` failed with a 403, retry on the global endpoint + retry_signed_request_on_global(signed_headers) + + # Handle any network failures with a generic verification error + rescue StandardError => e + raise(Errors::Authentication::AuthnIam::VerificationError.new(e)) + end + end + + # Retry request on AWS STS global endpoint + def retry_signed_request_on_global(signed_headers) + @logger.debug( + LogMessages::Authentication::AuthnIam::RetryWithGlobalEndpoint.new + ) + aws_request = URI('https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15') begin @client.get_response(aws_request, signed_headers) @@ -76,6 +96,17 @@ def response_from_signed_request(aws_headers) body.dig('ErrorResponse', 'Error', 'Message').to_s.strip ) end + + # Extract AWS region from the authorization header's credential string, i.e.: + # Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/sts/aws4_request + def extract_sts_host(signed_headers) + return signed_headers['host'] if signed_headers['host'].present? + + region = signed_headers['authorization'].match(%r{Credential=[^/]+/[^/]+/([^/]+)/})&.captures&.first + raise(Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header') unless region + + "sts.#{region}.amazonaws.com" + end end end end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 36f17b42b5..3fafa69f1a 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -324,6 +324,11 @@ module AuthnIam code: "CONJ00036D" ) + RetryWithGlobalEndpoint = ::Util::TrackableLogMessageClass.new( + msg: "Retrying IAM request signed in 'us-east-1' region with global STS endpoint.", + code: "CONJ00043D" + ) + end module AuthnAzure diff --git a/config/environments/development.rb b/config/environments/development.rb index d3b9a48acb..e6ba5ad073 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -15,7 +15,7 @@ # https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization # Accept multiple hosts for parallel tests - config.hosts << /^conjur[0-9]*$/ + config.hosts << /conjur[0-9]*/ # eager_load needed to make authentication work without the hacky # loading code... diff --git a/dev/start b/dev/start index adc1e9257c..235fe2f8e3 100755 --- a/dev/start +++ b/dev/start @@ -4,10 +4,6 @@ set -ex set -o pipefail -# CC servers can't find it for some reason. Local shellcheck is fine. -# shellcheck disable=SC1091 -source "../ci/oauth/keycloak/keycloak_functions.sh" - # SCRIPT GLOBAL STATE # Set up VERSION file for local development @@ -232,7 +228,14 @@ configure_oidc_authenticators() { } setup_keycloak() { - # Start keycloak docker compose service + + pushd "../ci" + # CC servers can't find it for some reason. Local shellcheck is fine. + # shellcheck disable=SC1091 + source "oauth/keycloak/keycloak_functions.sh" + popd + + # Start keycloak docker-compose service services+=(keycloak) docker compose up -d --no-deps "${services[@]}" diff --git a/spec/app/domain/authentication/authn_iam/authenticator_spec.rb b/spec/app/domain/authentication/authn_iam/authenticator_spec.rb index 0eadf4f528..e38b792544 100644 --- a/spec/app/domain/authentication/authn_iam/authenticator_spec.rb +++ b/spec/app/domain/authentication/authn_iam/authenticator_spec.rb @@ -9,6 +9,8 @@ # Good headers let(:valid_global_headers) { '{"host":"sts.amazonaws.com","x-amz-date":"20230518T152525Z","x-amz-security-token":"IQoJb3JpZ2luX2VjEDcaDGV1LWNlbnRyYWwtMSJGMEQCIB7Inb5Q2L+Tu0nDM6KpYkqjXetnd6Eizw9gt3c27+okAiBJHE59aL6HuIszrEmk7/SaclqSmEZ5xUmEJ10Hla8PYyq8BQhgEAMaDDE4ODk0NTc2OTAwOCIMg1WLlzrcLY1FHaQKKpkFcEmdxNL2sGdxHOhoLv5hf0pQsOhWciwMgGhGpSUM7gXSG6Z8Rqv7q1rqgl04BjxLZshOOJYdIfUjUNccgiWBofjRYNH5SGeuLuu7XfrtmGmToPSejG5vY76PFXyef4bRNpgkB2aTRjRIg/fInQF0y2oILUSbX4nUcvcJJpss7VnpGRgGqGE+HtatAnfAn0cVoPHs/I2oZslP9y2IVVAEbCxKhYUcHniCJzrmTrppczyd8bwC36wdhcN88YtUES3Z0sS9Ho3JcLbxeAnoqkuPng1VYLAt5uELOqqYWSsdqVFwc+PJ5JoBAJSab1dyPp/YhTg+Lp8pYm/cgSR4lygqgeElL+30tI77GFuMdxdnWymk1mvXtcr0Q/eOMpAETohivAEW+m4CglAEF3VTdLQXODKCvxsiUmsN5AanX7eKGeHPfy/mcM/srWWkVNeRk//DLFNSgfiaeBQeUpzRDEgVpweA4tf5xjW8oUxp9hK9Q872dxgye07bFLNXbyqK9NPAGIcD4/7Tw73kPxYSvZ+7wajgkGdcuIz91GR973QG2k5e82PwiiROOm8qEJo7QzPX6hF5s//qSnKr0vGo4tBrewKvQVjhafdf+B0b44jgw7kIsUkAYz/1HY2PPQVvVn5DayO7VxCTTd+VrQScr22ExOucIKEw30j5hCkhRnUMS6MAjsYwclSbPuUeZ8rVvFQSEoC37SQZP13OVphFNUUFVhZJU5kYd9BvaFhPO0F54XJq1Gu/biMJFumHj2lz5TadSXxyJwSJkfqE53B9b6Sc1RB7O4AckozmH/pm46fJpD5sq7RLQg9YLA//fi+EoBwqbiskCz7WRsCh/Q2l/dmA9kAPPVatqkagVdcJKAJNl8W4Y9nm9MBhdFIw4PeYowY6sgF2td8Xkg0E4ClmQ3CoGWO+TTWmiWwN0BAcKD5prq3V9qmn3SXnzLUO02k0gC4RsdGUCSiByzNHL3mG+G7l8MEZ8TmZQ+ZCgtlnvLYsdECiT+7tcrwN8Zpiu0BKcAWSV8Mg5/D0HYfDTFAv7jgqXuL+uruLJw4xzZCoWc5Ru1Mhprl4NF8T9Am2RyEEEO9rjveQCWpzaZmyvx/MS4isoqhC2KGeJEyeulk2XE0mXO6TZz9c","x-amz-content-sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","authorization":"AWS4-HMAC-SHA256 Credential=ASIASX7QLUIYGDI4QX56/20230518/us-east-1/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=81b929060b45f05470c9f542a9e46cdce51d37c1d22a58d1942b7fa175079af5"}' } let(:valid_regional_headers) { '{"host":"sts.eu-central-1.amazonaws.com","x-amz-date":"20230518T152442Z","x-amz-security-token":"IQoJb3JpZ2luX2VjEDcaDGV1LWNlbnRyYWwtMSJGMEQCIB7Inb5Q2L+Tu0nDM6KpYkqjXetnd6Eizw9gt3c27+okAiBJHE59aL6HuIszrEmk7/SaclqSmEZ5xUmEJ10Hla8PYyq8BQhgEAMaDDE4ODk0NTc2OTAwOCIMg1WLlzrcLY1FHaQKKpkFcEmdxNL2sGdxHOhoLv5hf0pQsOhWciwMgGhGpSUM7gXSG6Z8Rqv7q1rqgl04BjxLZshOOJYdIfUjUNccgiWBofjRYNH5SGeuLuu7XfrtmGmToPSejG5vY76PFXyef4bRNpgkB2aTRjRIg/fInQF0y2oILUSbX4nUcvcJJpss7VnpGRgGqGE+HtatAnfAn0cVoPHs/I2oZslP9y2IVVAEbCxKhYUcHniCJzrmTrppczyd8bwC36wdhcN88YtUES3Z0sS9Ho3JcLbxeAnoqkuPng1VYLAt5uELOqqYWSsdqVFwc+PJ5JoBAJSab1dyPp/YhTg+Lp8pYm/cgSR4lygqgeElL+30tI77GFuMdxdnWymk1mvXtcr0Q/eOMpAETohivAEW+m4CglAEF3VTdLQXODKCvxsiUmsN5AanX7eKGeHPfy/mcM/srWWkVNeRk//DLFNSgfiaeBQeUpzRDEgVpweA4tf5xjW8oUxp9hK9Q872dxgye07bFLNXbyqK9NPAGIcD4/7Tw73kPxYSvZ+7wajgkGdcuIz91GR973QG2k5e82PwiiROOm8qEJo7QzPX6hF5s//qSnKr0vGo4tBrewKvQVjhafdf+B0b44jgw7kIsUkAYz/1HY2PPQVvVn5DayO7VxCTTd+VrQScr22ExOucIKEw30j5hCkhRnUMS6MAjsYwclSbPuUeZ8rVvFQSEoC37SQZP13OVphFNUUFVhZJU5kYd9BvaFhPO0F54XJq1Gu/biMJFumHj2lz5TadSXxyJwSJkfqE53B9b6Sc1RB7O4AckozmH/pm46fJpD5sq7RLQg9YLA//fi+EoBwqbiskCz7WRsCh/Q2l/dmA9kAPPVatqkagVdcJKAJNl8W4Y9nm9MBhdFIw4PeYowY6sgF2td8Xkg0E4ClmQ3CoGWO+TTWmiWwN0BAcKD5prq3V9qmn3SXnzLUO02k0gC4RsdGUCSiByzNHL3mG+G7l8MEZ8TmZQ+ZCgtlnvLYsdECiT+7tcrwN8Zpiu0BKcAWSV8Mg5/D0HYfDTFAv7jgqXuL+uruLJw4xzZCoWc5Ru1Mhprl4NF8T9Am2RyEEEO9rjveQCWpzaZmyvx/MS4isoqhC2KGeJEyeulk2XE0mXO6TZz9c","x-amz-content-sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","authorization":"AWS4-HMAC-SHA256 Credential=ASIASX7QLUIYGDI4QX56/20230518/eu-central-1/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=8e0bebec9a3ce860b4595a4b27710da558a157a711e2dcbfe3a86881af99c459"}' } + let(:valid_global_headers_no_host) { '{"Authorization":"AWS4-HMAC-SHA256 Credential=ASIAYNRQATHTPJYFX7PM/20230613/us-east-1/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=e81afd905d5131d697e33ad38a5eff72789ed3e6b5e61d694212fbfe09684a73","X-Amz-Date":"20230613T204702Z","X-Amz-Security-Token":"FwoGZXIvYXdzEL7//////////wEaDG6/NsUxeWDT4wob/iKyAbExJrQ9Qr0pVX3lkwxaYwvssq/xFKk7Iu8w5uQsbsjZtqz7s8oNBfjuR/J7rRvDiFk4pyTICA9vzFEpNK1f4U3hfDslZFKhkeGgnY5jA2RLOlffE51tmvMr+KN6AJPJ+drAI5+K1Kn1G8Aiy5lsBHzEc0HR1Ji8zjujaqOWpZKYVC1MgIQt+l9eRdZTHBI/yb0fm32ZGxu/jMPZsa/kdGoDuAMd4pCZPnkaSnPgCNjJq5IoxqujpAYyLTMzCd3aidLr/ziL8UyEUbGJhglnhYEsDKp/ErjfnvoadZEuFIIpBKHbM01Igg=="}' } + let(:valid_regional_headers_no_host) { '{"Authorization":"AWS4-HMAC-SHA256 Credential=ASIAYNRQATHTEQFKJN2P/20230613/us-east-1/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=38f8b60e4cbee78d55f379646e4fde87439ce0e43cd4cae38e3d2e295ebcfc58","X-Amz-Date":"20230613T204734Z","X-Amz-Security-Token":"FwoGZXIvYXdzEL7//////////wEaDAHHPZ7NyIBlfbsv4iKyATWQ4LpJCG6fsa1UR0jYrTMF0FSxCu/otBw8qZNNljSWHaEIkh/h3GfImEJjYXytE3N92XPXahQomVErEpcyOBO3M/FDbMKZ7tlTD1V5Rr8ZgMG6tOLCL4eCKq2IbugKBZo1Bw8OxC20sjZWNL44Z/8Lt6LkOsHJBiN1wEAEtT5Wrt5Jc0Qs8oU8xV6RHpQRfOOM6V1BnqDjrnJG3cUguotSpfR2RyskUZNr+lRg+MfJOJ8o5qujpAYyLew0iNCK3nlXngTuzSo6M3rPAKQhbK1tCvKSIMk6SqrHThyfAebPucCZx/XbbA=="}' } # Bad headers let(:expired_headers) { '{"host":"sts.eu-central-1.amazonaws.com","x-amz-date":"20230518T152442Z","x-amz-security-token":"IQoJb3JpZ2luX2VjEDcaDGV1LWNlbnRyYWwtMSJGMEQCIB7Inb5Q2L+Tu0nDM6KpYkqjXetnd6Eizw9gt3c27+okAiBJHE59aL6HuIszrEmk7/SaclqSmEZ5xUmEJ10Hla8PYyq8BQhgEAMaDDE4ODk0NTc2OTAwOCIMg1WLlzrcLY1FHaQKKpkFcEmdxNL2sGdxHOhoLv5hf0pQsOhWciwMgGhGpSUM7gXSG6Z8Rqv7q1rqgl04BjxLZshOOJYdIfUjUNccgiWBofjRYNH5SGeuLuu7XfrtmGmToPSejG5vY76PFXyef4bRNpgkB2aTRjRIg/fInQF0y2oILUSbX4nUcvcJJpss7VnpGRgGqGE+HtatAnfAn0cVoPHs/I2oZslP9y2IVVAEbCxKhYUcHniCJzrmTrppczyd8bwC36wdhcN88YtUES3Z0sS9Ho3JcLbxeAnoqkuPng1VYLAt5uELOqqYWSsdqVFwc+PJ5JoBAJSab1dyPp/YhTg+Lp8pYm/cgSR4lygqgeElL+30tI77GFuMdxdnWymk1mvXtcr0Q/eOMpAETohivAEW+m4CglAEF3VTdLQXODKCvxsiUmsN5AanX7eKGeHPfy/mcM/srWWkVNeRk//DLFNSgfiaeBQeUpzRDEgVpweA4tf5xjW8oUxp9hK9Q872dxgye07bFLNXbyqK9NPAGIcD4/7Tw73kPxYSvZ+7wajgkGdcuIz91GR973QG2k5e82PwiiROOm8qEJo7QzPX6hF5s//qSnKr0vGo4tBrewKvQVjhafdf+B0b44jgw7kIsUkAYz/1HY2PPQVvVn5DayO7VxCTTd+VrQScr22ExOucIKEw30j5hCkhRnUMS6MAjsYwclSbPuUeZ8rVvFQSEoC37SQZP13OVphFNUUFVhZJU5kYd9BvaFhPO0F54XJq1Gu/biMJFumHj2lz5TadSXxyJwSJkfqE53B9b6Sc1RB7O4AckozmH/pm46fJpD5sq7RLQg9YLA//fi+EoBwqbiskCz7WRsCh/Q2l/dmA9kAPPVatqkagVdcJKAJNl8W4Y9nm9MBhdFIw4PeYowY6sgF2td8Xkg0E4ClmQ3CoGWO+TTWmiWwN0BAcKD5prq3V9qmn3SXnzLUO02k0gC4RsdGUCSiByzNHL3mG+G7l8MEZ8TmZQ+ZCgtlnvLYsdECiT+7tcrwN8Zpiu0BKcAWSV8Mg5/D0HYfDTFAv7jgqXuL+uruLJw4xzZCoWc5Ru1Mhprl4NF8T9Am2RyEEEO9rjveQCWpzaZmyvx/MS4isoqhC2KGeJEyeulk2XE0mXO6TZz9c","x-amz-content-sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","authorization":"AWS4-HMAC-SHA256 Credential=ASIASX7QLUIYGDI4QX56/20230518/eu-central-1/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=8e0bebec9a3ce860b4595a4b27710da558a157a711e2dcbfe3a86881af99c459"}' } @@ -16,6 +18,7 @@ let(:different_signing_request_verb) { '{"host":"sts.amazonaws.com","x-amz-date":"20230518T153042Z","x-amz-security-token":"IQoJb3JpZ2luX2VjEDcaDGV1LWNlbnRyYWwtMSJGMEQCIB7Inb5Q2L+Tu0nDM6KpYkqjXetnd6Eizw9gt3c27+okAiBJHE59aL6HuIszrEmk7/SaclqSmEZ5xUmEJ10Hla8PYyq8BQhgEAMaDDE4ODk0NTc2OTAwOCIMg1WLlzrcLY1FHaQKKpkFcEmdxNL2sGdxHOhoLv5hf0pQsOhWciwMgGhGpSUM7gXSG6Z8Rqv7q1rqgl04BjxLZshOOJYdIfUjUNccgiWBofjRYNH5SGeuLuu7XfrtmGmToPSejG5vY76PFXyef4bRNpgkB2aTRjRIg/fInQF0y2oILUSbX4nUcvcJJpss7VnpGRgGqGE+HtatAnfAn0cVoPHs/I2oZslP9y2IVVAEbCxKhYUcHniCJzrmTrppczyd8bwC36wdhcN88YtUES3Z0sS9Ho3JcLbxeAnoqkuPng1VYLAt5uELOqqYWSsdqVFwc+PJ5JoBAJSab1dyPp/YhTg+Lp8pYm/cgSR4lygqgeElL+30tI77GFuMdxdnWymk1mvXtcr0Q/eOMpAETohivAEW+m4CglAEF3VTdLQXODKCvxsiUmsN5AanX7eKGeHPfy/mcM/srWWkVNeRk//DLFNSgfiaeBQeUpzRDEgVpweA4tf5xjW8oUxp9hK9Q872dxgye07bFLNXbyqK9NPAGIcD4/7Tw73kPxYSvZ+7wajgkGdcuIz91GR973QG2k5e82PwiiROOm8qEJo7QzPX6hF5s//qSnKr0vGo4tBrewKvQVjhafdf+B0b44jgw7kIsUkAYz/1HY2PPQVvVn5DayO7VxCTTd+VrQScr22ExOucIKEw30j5hCkhRnUMS6MAjsYwclSbPuUeZ8rVvFQSEoC37SQZP13OVphFNUUFVhZJU5kYd9BvaFhPO0F54XJq1Gu/biMJFumHj2lz5TadSXxyJwSJkfqE53B9b6Sc1RB7O4AckozmH/pm46fJpD5sq7RLQg9YLA//fi+EoBwqbiskCz7WRsCh/Q2l/dmA9kAPPVatqkagVdcJKAJNl8W4Y9nm9MBhdFIw4PeYowY6sgF2td8Xkg0E4ClmQ3CoGWO+TTWmiWwN0BAcKD5prq3V9qmn3SXnzLUO02k0gC4RsdGUCSiByzNHL3mG+G7l8MEZ8TmZQ+ZCgtlnvLYsdECiT+7tcrwN8Zpiu0BKcAWSV8Mg5/D0HYfDTFAv7jgqXuL+uruLJw4xzZCoWc5Ru1Mhprl4NF8T9Am2RyEEEO9rjveQCWpzaZmyvx/MS4isoqhC2KGeJEyeulk2XE0mXO6TZz9c","x-amz-content-sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","authorization":"AWS4-HMAC-SHA256 Credential=ASIASX7QLUIYGDI4QX56/20230518/us-east-1/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=bd7f1e549999b6854adfb7c35e344efa38d74bac1c9078fbc34f71f4ff90173b"}' } let(:global_non_global_signer) { '{"host":"sts.amazonaws.com","x-amz-date":"20230518T155824Z","x-amz-security-token":"IQoJb3JpZ2luX2VjEDgaDGV1LWNlbnRyYWwtMSJIMEYCIQDQLBDLt5kNEEj40A2gX/kurzw9AAwYKnJIjQSrXGynmQIhAINJPoMDfHiSe6Bjzuwc9lwbe/iUPkVUUClFbUicPhNtKrwFCGEQAxoMMTg4OTQ1NzY5MDA4IgyYkP5L6wWxumObWPQqmQXd1mkF8hU1CCkhdJdc73vLVnGFvGbqru9SpzDIkbo0/syIZR7pjEoPTw/sUxFa/jYgcRrDh9bUmTBT7zU2/HDJRrwZfNgu9PPEtRGkIUOYri8muc0X8yLnHlk/5jvpwU5RY2uUoTZt+pvgm8jOHi/lkrtPx8uiQeEOeBkKe/DA2tFfHeVrC9OPkWQkjm0k9nsDggpwy9jf+RbzeSRb6Tyg4WWplDKW+a7VhBpBs1cNGGx9um590gpgpxaC93CIAtxI+iOxreH+xofhrJAPirms1yR4f5GD+glhr/mpCdclX0Eehvb+nxosnYKMh8XvlKR1GzP/PtqGCCRMpCByPmiXtotOMEQv96QGGs0/A8vdiktjJuGbjSQxKvw1UNG5Cxrb7DRqexPoILxoZAICSiFDK2vPPl2ePTWrk3MFxBKj+UK2VihhE1fpnZ7UXrVmQAUzNtDTdaaTcWy2ZTf4oAcRkUsYUl1BOIQYEcI0LmpCkqHb51ANbPO9bFxLAGTUuV9AMSRcrEojiFlxl+yiK5lPygJzrbRQBhnh3srxoHjfN5KvgVf0PqS3HUWIX/eknhBTJbhPO9d7me1/rTudqFd1dLEEiTJbN6KGF0vZvAgnahcoA4YSxLhG/EkNdOKbS/lJOUeysN/l/3L19RuWX9gd2Pc2FD7KWM6mzNAFIi8OAIChTnhZO7a6lcYZb+5uY+5N3Oh2Qthx7cc5k0DGQXX2FGYQgQKY9MWQo6s3Z2DwfNDddgYEM2clnnNPP+lNU7evdCsDb4/QPyDv+l14Tmdz2adhcsi4hkX9YGaCko6S6tdPI1fURyTkCKfUJ643ZNXyqmx4vRPupO2ukJArh7piCas8+B4FsScjD1sD3dH9aVwa2sI1tVed9zCclpmjBjqwATqNRvOZinA+mqThE4YjTVliRSaqNG6UsCq/x3f6R5/KBMacp4f0nZuXTOfvNgiFHju/m1paGf8JwylAkKummhneSOoZAbQ16dQhDu3ejZydbj5nRkTC/1VT87SFf+S+k66J8ycfIs2nkwlIvHWo+TbD36fBcTNq69BtAvluRRIH77zXg60zBK5KmmtKUbeayHVZngjZt/u3ezrRGuLu9TUCYtWMY0VVKyYUX36MykWY","x-amz-content-sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","authorization":"AWS4-HMAC-SHA256 Credential=ASIASX7QLUIYGFOUEXPF/20230518/eu-north-1/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=e643e2bb5d737954951f34ff67c4a11e00d022b402579f85ff9d99a5f70cbc53"}' } let(:regional_headers_signed_for_another_region) { '{"host":"sts.us-west-1.amazonaws.com","x-amz-date":"20230518T160804Z","x-amz-security-token":"IQoJb3JpZ2luX2VjEDgaDGV1LWNlbnRyYWwtMSJIMEYCIQDQLBDLt5kNEEj40A2gX/kurzw9AAwYKnJIjQSrXGynmQIhAINJPoMDfHiSe6Bjzuwc9lwbe/iUPkVUUClFbUicPhNtKrwFCGEQAxoMMTg4OTQ1NzY5MDA4IgyYkP5L6wWxumObWPQqmQXd1mkF8hU1CCkhdJdc73vLVnGFvGbqru9SpzDIkbo0/syIZR7pjEoPTw/sUxFa/jYgcRrDh9bUmTBT7zU2/HDJRrwZfNgu9PPEtRGkIUOYri8muc0X8yLnHlk/5jvpwU5RY2uUoTZt+pvgm8jOHi/lkrtPx8uiQeEOeBkKe/DA2tFfHeVrC9OPkWQkjm0k9nsDggpwy9jf+RbzeSRb6Tyg4WWplDKW+a7VhBpBs1cNGGx9um590gpgpxaC93CIAtxI+iOxreH+xofhrJAPirms1yR4f5GD+glhr/mpCdclX0Eehvb+nxosnYKMh8XvlKR1GzP/PtqGCCRMpCByPmiXtotOMEQv96QGGs0/A8vdiktjJuGbjSQxKvw1UNG5Cxrb7DRqexPoILxoZAICSiFDK2vPPl2ePTWrk3MFxBKj+UK2VihhE1fpnZ7UXrVmQAUzNtDTdaaTcWy2ZTf4oAcRkUsYUl1BOIQYEcI0LmpCkqHb51ANbPO9bFxLAGTUuV9AMSRcrEojiFlxl+yiK5lPygJzrbRQBhnh3srxoHjfN5KvgVf0PqS3HUWIX/eknhBTJbhPO9d7me1/rTudqFd1dLEEiTJbN6KGF0vZvAgnahcoA4YSxLhG/EkNdOKbS/lJOUeysN/l/3L19RuWX9gd2Pc2FD7KWM6mzNAFIi8OAIChTnhZO7a6lcYZb+5uY+5N3Oh2Qthx7cc5k0DGQXX2FGYQgQKY9MWQo6s3Z2DwfNDddgYEM2clnnNPP+lNU7evdCsDb4/QPyDv+l14Tmdz2adhcsi4hkX9YGaCko6S6tdPI1fURyTkCKfUJ643ZNXyqmx4vRPupO2ukJArh7piCas8+B4FsScjD1sD3dH9aVwa2sI1tVed9zCclpmjBjqwATqNRvOZinA+mqThE4YjTVliRSaqNG6UsCq/x3f6R5/KBMacp4f0nZuXTOfvNgiFHju/m1paGf8JwylAkKummhneSOoZAbQ16dQhDu3ejZydbj5nRkTC/1VT87SFf+S+k66J8ycfIs2nkwlIvHWo+TbD36fBcTNq69BtAvluRRIH77zXg60zBK5KmmtKUbeayHVZngjZt/u3ezrRGuLu9TUCYtWMY0VVKyYUX36MykWY","x-amz-content-sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","authorization":"AWS4-HMAC-SHA256 Credential=ASIASX7QLUIYGFOUEXPF/20230518/eu-north-1/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=bfaefe50abefa68b0081af52687885a7391006f2c8b0d80b6c20beb7da808d56"}' } + let(:invalid_authorization_header) { '{"Authorization":"AWS4-HMAC-SHA256 Credential=bad_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=38f8b60e4cbee78d55f379646e4fde87439ce0e43cd4cae38e3d2e295ebcfc58","X-Amz-Date":"20230613T204734Z","X-Amz-Security-Token":"FwoGZXIvYXdzEL7//////////wEaDAHHPZ7NyIBlfbsv4iKyATWQ4LpJCG6fsa1UR0jYrTMF0FSxCu/otBw8qZNNljSWHaEIkh/h3GfImEJjYXytE3N92XPXahQomVErEpcyOBO3M/FDbMKZ7tlTD1V5Rr8ZgMG6tOLCL4eCKq2IbugKBZo1Bw8OxC20sjZWNL44Z/8Lt6LkOsHJBiN1wEAEtT5Wrt5Jc0Qs8oU8xV6RHpQRfOOM6V1BnqDjrnJG3cUguotSpfR2RyskUZNr+lRg+MfJOJ8o5qujpAYyLew0iNCK3nlXngTuzSo6M3rPAKQhbK1tCvKSIMk6SqrHThyfAebPucCZx/XbbA=="}' } describe '.valid?' do context 'headers are valid' do @@ -28,6 +31,14 @@ expect(authenticator.valid?(payload)).to be(true) end end + context 'with request signed by `us-east-1` and no `host` header' do + let(:payload) do + double('AuthenticationParameters', credentials: valid_global_headers_no_host, username: conjur_role) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-global-headers-no-host' do + expect(authenticator.valid?(payload)).to be(true) + end + end context 'with request signed for a non `us-east-1` region' do let(:payload) do double('AuthenticationParameters', credentials: global_non_global_signer, username: conjur_role) @@ -50,6 +61,14 @@ expect(authenticator.valid?(payload)).to be(true) end end + context 'when regional endpoint request was signed for that region and no `host` header' do + let(:payload) do + double('AuthenticationParameters', credentials: valid_regional_headers_no_host, username: conjur_role) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-regional-headers-no-host' do + expect(authenticator.valid?(payload)).to be(true) + end + end context 'when regional endpoint request was signed for another region' do let(:payload) do double('AuthenticationParameters', credentials: regional_headers_signed_for_another_region, username: conjur_role) @@ -103,6 +122,18 @@ end end end + context 'when the authorization header is invalid' do + let(:payload) do + double('AuthenticationParameters', credentials: invalid_authorization_header, username: conjur_role) + end + it 'fails' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + 'CONJ00018E Invalid or expired AWS headers: Failed to extract AWS region from authorization header' + ) + end + end end context 'when an http exception occurs' do let(:authenticator) do diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers-no-host.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers-no-host.yml new file mode 100644 index 0000000000..8ee936b4e9 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers-no-host.yml @@ -0,0 +1,95 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.us-east-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - AWS4-HMAC-SHA256 Credential=ASIAYNRQATHTPJYFX7PM/20230613/us-east-1/sts/aws4_request, + SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=e81afd905d5131d697e33ad38a5eff72789ed3e6b5e61d694212fbfe09684a73 + X-Amz-Date: + - 20230613T204702Z + X-Amz-Security-Token: + - FwoGZXIvYXdzEL7//////////wEaDG6/NsUxeWDT4wob/iKyAbExJrQ9Qr0pVX3lkwxaYwvssq/xFKk7Iu8w5uQsbsjZtqz7s8oNBfjuR/J7rRvDiFk4pyTICA9vzFEpNK1f4U3hfDslZFKhkeGgnY5jA2RLOlffE51tmvMr+KN6AJPJ+drAI5+K1Kn1G8Aiy5lsBHzEc0HR1Ji8zjujaqOWpZKYVC1MgIQt+l9eRdZTHBI/yb0fm32ZGxu/jMPZsa/kdGoDuAMd4pCZPnkaSnPgCNjJq5IoxqujpAYyLTMzCd3aidLr/ziL8UyEUbGJhglnhYEsDKp/ErjfnvoadZEuFIIpBKHbM01Igg== + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 403 + message: Forbidden + headers: + X-Amzn-Requestid: + - cbe271c4-359d-4aaf-b7b2-810ec7d073ea + Content-Type: + - text/xml + Content-Length: + - '431' + Date: + - Tue, 13 Jun 2023 20:48:30 GMT + body: + encoding: UTF-8 + string: | + + + Sender + SignatureDoesNotMatch + The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details. + + cbe271c4-359d-4aaf-b7b2-810ec7d073ea + + recorded_at: Tue, 13 Jun 2023 20:48:31 GMT +- request: + method: get + uri: https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - AWS4-HMAC-SHA256 Credential=ASIAYNRQATHTPJYFX7PM/20230613/us-east-1/sts/aws4_request, + SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=e81afd905d5131d697e33ad38a5eff72789ed3e6b5e61d694212fbfe09684a73 + X-Amz-Date: + - 20230613T204702Z + X-Amz-Security-Token: + - FwoGZXIvYXdzEL7//////////wEaDG6/NsUxeWDT4wob/iKyAbExJrQ9Qr0pVX3lkwxaYwvssq/xFKk7Iu8w5uQsbsjZtqz7s8oNBfjuR/J7rRvDiFk4pyTICA9vzFEpNK1f4U3hfDslZFKhkeGgnY5jA2RLOlffE51tmvMr+KN6AJPJ+drAI5+K1Kn1G8Aiy5lsBHzEc0HR1Ji8zjujaqOWpZKYVC1MgIQt+l9eRdZTHBI/yb0fm32ZGxu/jMPZsa/kdGoDuAMd4pCZPnkaSnPgCNjJq5IoxqujpAYyLTMzCd3aidLr/ziL8UyEUbGJhglnhYEsDKp/ErjfnvoadZEuFIIpBKHbM01Igg== + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Amzn-Requestid: + - cfc12bee-f331-403e-b281-2b5ed0cca8bd + Content-Type: + - text/xml + Content-Length: + - '444' + Date: + - Tue, 13 Jun 2023 20:48:31 GMT + body: + encoding: UTF-8 + string: | + + + arn:aws:sts::188945769008:assumed-role/conjur-role/i-08241b0e31fe23d20 + AROASX7QLUIYK4AQBODTV:i-08241b0e31fe23d20 + 188945769008 + + + c025e1ba-c36b-4078-9407-fdd02eaee5aa + + + recorded_at: Tue, 13 Jun 2023 20:48:31 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers-no-host.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers-no-host.yml new file mode 100644 index 0000000000..82ab0d193e --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers-no-host.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.us-east-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - AWS4-HMAC-SHA256 Credential=ASIAYNRQATHTEQFKJN2P/20230613/us-east-1/sts/aws4_request, + SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=38f8b60e4cbee78d55f379646e4fde87439ce0e43cd4cae38e3d2e295ebcfc58 + X-Amz-Date: + - 20230613T204734Z + X-Amz-Security-Token: + - FwoGZXIvYXdzEL7//////////wEaDAHHPZ7NyIBlfbsv4iKyATWQ4LpJCG6fsa1UR0jYrTMF0FSxCu/otBw8qZNNljSWHaEIkh/h3GfImEJjYXytE3N92XPXahQomVErEpcyOBO3M/FDbMKZ7tlTD1V5Rr8ZgMG6tOLCL4eCKq2IbugKBZo1Bw8OxC20sjZWNL44Z/8Lt6LkOsHJBiN1wEAEtT5Wrt5Jc0Qs8oU8xV6RHpQRfOOM6V1BnqDjrnJG3cUguotSpfR2RyskUZNr+lRg+MfJOJ8o5qujpAYyLew0iNCK3nlXngTuzSo6M3rPAKQhbK1tCvKSIMk6SqrHThyfAebPucCZx/XbbA== + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Amzn-Requestid: + - e7b366a8-5a46-4676-9545-c28dd1b9a4ce + Content-Type: + - text/xml + Content-Length: + - '444' + Date: + - Tue, 13 Jun 2023 20:48:30 GMT + body: + encoding: UTF-8 + string: | + + + arn:aws:sts::188945769008:assumed-role/conjur-role/i-08241b0e31fe23d20 + AROASX7QLUIYK4AQBODTV:i-08241b0e31fe23d20 + 188945769008 + + + c025e1ba-c36b-4078-9407-fdd02eaee5aa + + + recorded_at: Tue, 13 Jun 2023 20:48:30 GMT +recorded_with: VCR 6.1.0 From 851caf2278e4fe7accff712113b4664125f6a000 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Thu, 6 Jul 2023 08:34:33 -0600 Subject: [PATCH 140/665] Separate call and retry logic (cherry picked from commit da7dff4c2525d2d343b09a6b6328f42223a260c9) --- .../authentication/authn_iam/authenticator.rb | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/app/domain/authentication/authn_iam/authenticator.rb b/app/domain/authentication/authn_iam/authenticator.rb index cfef517adb..cb83428a18 100755 --- a/app/domain/authentication/authn_iam/authenticator.rb +++ b/app/domain/authentication/authn_iam/authenticator.rb @@ -54,33 +54,34 @@ def extract_relevant_data(response) # Call to AWS STS endpoint using the provided authentication header def attempt_signed_request(signed_headers) - sts_host = extract_sts_host(signed_headers) - aws_request = URI("https://#{sts_host}/?Action=GetCallerIdentity&Version=2011-06-15") - begin - response = @client.get_response(aws_request, signed_headers) - return response unless response.code.to_i == 403 && sts_host.include?('us-east-1') - - # If the request to `us-east-1` failed with a 403, retry on the global endpoint - retry_signed_request_on_global(signed_headers) + region = extract_sts_region(signed_headers) - # Handle any network failures with a generic verification error - rescue StandardError => e - raise(Errors::Authentication::AuthnIam::VerificationError.new(e)) + # Attempt request using the discovered region and return immediately if successful + response = aws_call(region: region, headers: signed_headers) + return response if response.code.to_i == 200 + + # If the discovered region is `us-east-1`, fallback to the global endpoint + if region == 'us-east-1' + @logger.debug(LogMessages::Authentication::AuthnIam::RetryWithGlobalEndpoint.new) + fallback_response = aws_call(region: 'global', headers: signed_headers) + return fallback_response if fallback_response.code.to_i == 200 end + + return response end - # Retry request on AWS STS global endpoint - def retry_signed_request_on_global(signed_headers) - @logger.debug( - LogMessages::Authentication::AuthnIam::RetryWithGlobalEndpoint.new - ) - aws_request = URI('https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15') + def aws_call(region:, headers:) + host = if region == 'global' + 'sts.amazonaws.com' + else + "sts.#{region}.amazonaws.com" + end + aws_request = URI("https://#{host}/?Action=GetCallerIdentity&Version=2011-06-15") begin - @client.get_response(aws_request, signed_headers) - - # Handle any network failures with a generic verification error + @client.get_response(aws_request, headers) rescue StandardError => e - raise(Errors::Authentication::AuthnIam::VerificationError.new(e)) + # Handle any network failures with a generic verification error + raise(Errors::Authentication::AuthnIam::VerificationError, e) end end @@ -97,15 +98,23 @@ def response_from_signed_request(aws_headers) ) end - # Extract AWS region from the authorization header's credential string, i.e.: + # Extracts the STS region from the host header if it exists. + # If not, we use the authorization header's credential string, i.e.: # Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/sts/aws4_request - def extract_sts_host(signed_headers) - return signed_headers['host'] if signed_headers['host'].present? + def extract_sts_region(signed_headers) + host = signed_headers['host'] - region = signed_headers['authorization'].match(%r{Credential=[^/]+/[^/]+/([^/]+)/})&.captures&.first - raise(Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header') unless region + if host == 'sts.amazonaws.com' + return 'global' + end + + match = host&.match(%r{sts.([\w\-]+).amazonaws.com}) + return match.captures.first if match - "sts.#{region}.amazonaws.com" + match = signed_headers['authorization']&.match(%r{Credential=[^/]+/[^/]+/([^/]+)/}) + return match.captures.first if match + + raise Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header' end end end From 0b0ed2d543fab4026e6447918a1ebf31b1b469f1 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Thu, 29 Jun 2023 19:05:39 +0300 Subject: [PATCH 141/665] Update CONJ00013E error message to apply to AuthnOIDC V1 and V2 (cherry picked from commit 3f47274c29271040e99111b8f127368c18509c68) --- .../authn_oidc/update_input_with_username_from_id_token.rb | 6 ++++-- app/domain/authentication/authn_oidc/v2/strategy.rb | 6 ++++-- app/domain/errors.rb | 2 +- cucumber/authenticators_oidc/features/authn_oidc.feature | 2 +- cucumber/authenticators_oidc/features/authn_oidc_v2.feature | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index 903c5ad092..5e98e78a42 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -100,8 +100,10 @@ def required_variable_names def validate_conjur_username if conjur_username.empty? - raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty, - id_token_username_field + raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( + id_token_username_field, + "id-token-user-property" + ) end if conjur_username == "admin" diff --git a/app/domain/authentication/authn_oidc/v2/strategy.rb b/app/domain/authentication/authn_oidc/v2/strategy.rb index 4e95e40656..ef7f950362 100644 --- a/app/domain/authentication/authn_oidc/v2/strategy.rb +++ b/app/domain/authentication/authn_oidc/v2/strategy.rb @@ -28,8 +28,10 @@ def callback(args) ) ) unless identity.present? - raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty, - @authenticator.claim_mapping + raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( + @authenticator.claim_mapping, + "claim-mapping" + ) end identity end diff --git a/app/domain/errors.rb b/app/domain/errors.rb index a339a79a05..2a9c59c5a3 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -247,7 +247,7 @@ module Jwt module AuthnOidc IdTokenClaimNotFoundOrEmpty = ::Util::TrackableErrorClass.new( msg: "Claim '{0-claim-name}' not found or empty in ID token. " \ - "This claim is defined in the id-token-user-property variable.", + "This claim is defined in the {1-config-var} variable.", code: "CONJ00013E" ) diff --git a/cucumber/authenticators_oidc/features/authn_oidc.feature b/cucumber/authenticators_oidc/features/authn_oidc.feature index b120b512ad..e3a5b124e1 100644 --- a/cucumber/authenticators_oidc/features/authn_oidc.feature +++ b/cucumber/authenticators_oidc/features/authn_oidc.feature @@ -155,7 +155,7 @@ Feature: OIDC Authenticator - Hosts can authenticate with OIDC authenticator Then it is unauthorized And The following appears in the log after my savepoint: """ - Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty + Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty: CONJ00013E Claim 'non_existing_field' not found or empty in ID token. This claim is defined in the id-token-user-property variable. """ @negative @acceptance diff --git a/cucumber/authenticators_oidc/features/authn_oidc_v2.feature b/cucumber/authenticators_oidc/features/authn_oidc_v2.feature index a0e258f2fb..53cce3076d 100644 --- a/cucumber/authenticators_oidc/features/authn_oidc_v2.feature +++ b/cucumber/authenticators_oidc/features/authn_oidc_v2.feature @@ -190,7 +190,7 @@ Feature: OIDC Authenticator V2 - Users can authenticate with OIDC authenticator Then it is unauthorized And The following appears in the log after my savepoint: """ - Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty + Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty: CONJ00013E Claim 'non_existing_field' not found or empty in ID token. This claim is defined in the claim-mapping variable. """ @negative @acceptance From 694f43191b02a054fa760d46f20acfd3bb93e896 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Tue, 11 Jul 2023 09:51:49 -0400 Subject: [PATCH 142/665] Parallel cukes: support socket connections to parallel Conjur services (cherry picked from commit ad02e85681a9a7408aabc05eb99585e9c14eefff) --- ci/docker-compose.yml | 2 +- ci/shared.sh | 15 +++++++++++++++ config/environments/test.rb | 1 + cucumber/api/features/support/env.rb | 1 + cucumber/api/features/support/hooks.rb | 1 + 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 5c3964f691..9b206fce8f 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -147,7 +147,7 @@ services: volumes: - ..:/src/conjur-server - authn-local:/run/authn-local - - authn-local2:/run/authn-local + - authn-local2:/run/authn-local2 - ./ldap-certs:/ldap-certs:ro - log-volume:/src/conjur-server/log - jwks-volume:/var/jwks diff --git a/ci/shared.sh b/ci/shared.sh index 21b9fc8dfe..385c335423 100644 --- a/ci/shared.sh +++ b/ci/shared.sh @@ -112,6 +112,16 @@ _run_cucumber_tests() { fi done + # Generate authn_local socket ENV variables based on the + # number of parallel processes. + for (( i=1; i <= ${#parallel_services[@]}; ++i )); do + if (( i == 1 )) ; then + sockets+=("AUTHN_LOCAL_SOCKET=/run/authn-local/.socket") + else + sockets+=("AUTHN_LOCAL_SOCKET${i}=/run/authn-local${i}/.socket") + fi + done + # Add the cucumber env vars that we always want to send. # Note: These are args for docker compose run, and as such the right hand # sides of the = do NOT require escaped quotes. docker compose takes the @@ -126,6 +136,11 @@ _run_cucumber_tests() { env_var_flags+=(-e "$api_key") done + # Add parallel process authn_local sockets to the env_var_flags + for socket in "${sockets[@]}"; do + env_var_flags+=(-e "$socket") + done + # If there's no tty (e.g. we're running as a Jenkins job), pass -T to # docker compose. run_flags=(--no-deps --rm) diff --git a/config/environments/test.rb b/config/environments/test.rb index db8a6d08dd..b8c1ff3fd6 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -7,6 +7,7 @@ parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] +parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars.each do |key, value| if ENV[key].nil? || ENV[key].empty? diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index e802ff8418..01a666eed7 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -8,6 +8,7 @@ parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] +parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars.each do |key, value| if ENV[key].nil? || ENV[key].empty? diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index c931dd46a4..ab994604af 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -15,6 +15,7 @@ parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] + parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars.each do |key, value| ENV[key] = value From 3a385015eb41a03199370c54029417a677dcac4c Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Tue, 11 Jul 2023 10:17:10 -0400 Subject: [PATCH 143/665] Dev env fixes - Fix invalid escape key in CLI script - Specify single process in start script (cherry picked from commit e56d01d0caf9c0d18a2469e38bbe529654ff4a41) --- ci/shared.sh | 2 +- dev/cli | 2 +- dev/start | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ci/shared.sh b/ci/shared.sh index 385c335423..4d2d47ee3c 100644 --- a/ci/shared.sh +++ b/ci/shared.sh @@ -4,7 +4,7 @@ export REPORT_ROOT=/src/conjur-server # Sets the number of parallel processes for cucumber tests # Due to naming conventions for parallel_cucumber this begins at 1 NOT 0 -PARALLEL_PROCESSES=2 +PARALLEL_PROCESSES=${PARALLEL_PROCESSES:-2} get_parallel_services() { # get_parallel_services converts docker service names diff --git a/dev/cli b/dev/cli index 3275332ccb..0b19ef34a2 100755 --- a/dev/cli +++ b/dev/cli @@ -246,7 +246,7 @@ while true ; do * ) if [ -z "$2" ]; then shift ; else echo "$2 is not a valid option"; exit 1; fi;; esac - docker exec "$env_args" -it --detach-keys "ctrl-\'" "$(docker compose ps -q conjur)" bash + docker exec "$env_args" -it --detach-keys 'ctrl-\' "$(docker compose ps -q conjur)" bash shift ;; policy ) case "$2" in diff --git a/dev/start b/dev/start index 235fe2f8e3..3960660df5 100755 --- a/dev/start +++ b/dev/start @@ -3,6 +3,7 @@ # NOTE: You must execute this script from this directory (dev). set -ex set -o pipefail +export PARALLEL_PROCESSES=1 # SCRIPT GLOBAL STATE From d020d05ccc912d95a86d92a4fc019e1d6d459aac Mon Sep 17 00:00:00 2001 From: Isaiah Peralta Date: Wed, 12 Jul 2023 13:54:51 -0400 Subject: [PATCH 144/665] Apply a check to run different compose versions (cherry picked from commit 00b1b5fa7593b5a29b65ba0bbe997cb56a909dd0) --- ci/oauth/keycloak/keycloak_functions.sh | 10 +++++----- ci/shared.sh | 24 ++++++++++++------------ ci/test | 10 +++++++++- ci/test_suites/authenticators_jwt/test | 4 ++-- ci/test_suites/authenticators_oidc/test | 4 ++-- ci/test_suites/rspec/test | 4 ++-- ci/test_suites/rspec_audit/test | 4 ++-- 7 files changed, 34 insertions(+), 26 deletions(-) diff --git a/ci/oauth/keycloak/keycloak_functions.sh b/ci/oauth/keycloak/keycloak_functions.sh index 845c28f0e3..a3f9065b22 100644 --- a/ci/oauth/keycloak/keycloak_functions.sh +++ b/ci/oauth/keycloak/keycloak_functions.sh @@ -16,7 +16,7 @@ function _hydrate_keycloak_env_args() { set -o pipefail # Note: This prints all lines that look like: # KEYCLOAK_XXX=someval - docker compose exec -T ${KEYCLOAK_SERVICE_NAME} printenv | awk '/KEYCLOAK/' + $COMPOSE exec -T ${KEYCLOAK_SERVICE_NAME} printenv | awk '/KEYCLOAK/' ) # shellcheck disable=SC2034 @@ -41,7 +41,7 @@ function _create_keycloak_user() { local pw_var=$2 local email_var=$3 - docker compose exec -T \ + $COMPOSE exec -T \ ${KEYCLOAK_SERVICE_NAME} \ bash -c "/scripts/create_user \"$user_var\" \"$pw_var\" \"$email_var\"" } @@ -49,7 +49,7 @@ function _create_keycloak_user() { function create_keycloak_users() { echo "Defining keycloak client" - docker compose exec -T ${KEYCLOAK_SERVICE_NAME} /scripts/create_client + $COMPOSE exec -T ${KEYCLOAK_SERVICE_NAME} /scripts/create_client echo "Creating user 'alice' in Keycloak" @@ -80,7 +80,7 @@ function create_keycloak_users() { } function wait_for_keycloak_server() { - docker compose exec -T \ + $COMPOSE exec -T \ ${KEYCLOAK_SERVICE_NAME} /scripts/wait_for_server } @@ -93,7 +93,7 @@ function fetch_keycloak_certificate() { read -ra parallel_services <<< "$(get_parallel_services 'conjur')" for parallel_service in "${parallel_services[@]}"; do - docker compose exec -T \ + $COMPOSE exec -T \ "${parallel_service}" /oauth/keycloak/scripts/fetch_certificate done } diff --git a/ci/shared.sh b/ci/shared.sh index 4d2d47ee3c..74aaf08f10 100644 --- a/ci/shared.sh +++ b/ci/shared.sh @@ -66,20 +66,20 @@ _run_cucumber_tests() { read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" if (( ${#services[@]} )); then - docker compose up --no-deps --no-recreate -d "${parallel_services[@]}" "${services[@]}" + $COMPOSE up --no-deps --no-recreate -d "${parallel_services[@]}" "${services[@]}" else - docker compose up --no-deps --no-recreate -d "${parallel_services[@]}" + $COMPOSE up --no-deps --no-recreate -d "${parallel_services[@]}" fi read -ra parallel_services <<< "$(get_parallel_services 'conjur')" for parallel_service in "${parallel_services[@]}"; do - docker compose exec -T "$parallel_service" conjurctl wait --retries 180 + $COMPOSE exec -T "$parallel_service" conjurctl wait --retries 180 done echo "Create cucumber account..." for parallel_service in "${parallel_services[@]}"; do - docker compose exec -T "$parallel_service" conjurctl account create cucumber + $COMPOSE exec -T "$parallel_service" conjurctl account create cucumber done # Stage 2: Prepare cucumber environment args @@ -168,7 +168,7 @@ _run_cucumber_tests() { # Have to add tags in profile for parallel to run properly # ${cucumber_tags_arg} should overwrite the profile tags in a way for @smoke to work correctly - docker compose run "${run_flags[@]}" "${env_var_flags[@]}" \ + $COMPOSE run "${run_flags[@]}" "${env_var_flags[@]}" \ cucumber -ec "\ /oauth/keycloak/scripts/fetch_certificate && bundle exec parallel_cucumber . -n ${PARALLEL_PROCESSES} \ @@ -185,24 +185,24 @@ _run_cucumber_tests() { # process to write the report. The container is kept alive using an infinite # sleep in the at_exit hook (see .simplecov). for parallel_service in "${parallel_services[@]}"; do - docker compose exec -T "$parallel_service" bash -c "pkill -f 'puma 5'" + $COMPOSE exec -T "$parallel_service" bash -c "pkill -f 'puma 5'" done } _get_api_key() { local service=$1 - docker compose exec -T "${service}" conjurctl \ + $COMPOSE exec -T "${service}" conjurctl \ role retrieve-key cucumber:user:admin | tr -d '\r' } _find_cucumber_network() { local net - # Docker compose conjur/pg services use the same + # docker compose conjur/pg services use the same # network for 1 or more instances so only conjur is passed # and not other parallel services. - conjur_id=$(docker compose ps -q conjur) + conjur_id=$($COMPOSE ps -q conjur) net=$(docker inspect "${conjur_id}" --format '{{.HostConfig.NetworkMode}}') docker network inspect "$net" \ @@ -233,7 +233,7 @@ wait_for_cmd() { _wait_for_pg() { local svc=$1 local pg_cmd=(psql -U postgres -c "select 1" -d postgres) - local dc_cmd=(docker compose exec -T "$svc" "${pg_cmd[@]}") + local dc_cmd=($COMPOSE exec -T "$svc" "${pg_cmd[@]}") echo "Waiting for pg to come up..." @@ -252,14 +252,14 @@ is_ldap_up() { # Note: We need the subshell to group the commands. ( set -o pipefail - docker compose exec -T ldap-server bash -c "$ldap_check_cmd" | + $COMPOSE exec -T ldap-server bash -c "$ldap_check_cmd" | grep '^search: 3$' ) >/dev/null 2>&1 } start_ldap_server() { # Start LDAP. - docker compose up --no-deps --detach ldap-server + $COMPOSE up --no-deps --detach ldap-server # Wait for up to 90 seconds, since it's slow. echo "Ensuring that LDAP is up..." diff --git a/ci/test b/ci/test index c48f2fa451..99b936534b 100755 --- a/ci/test +++ b/ci/test @@ -41,6 +41,14 @@ source "./ci/shared.sh" # shellcheck disable=SC1091 source "build_utils.sh" +# Create a value to determine if the runtime container +# for Jenkins can run Compose v2 syntax +COMPOSE="docker compose" +if grep -m 1 'Red Hat' /etc/os-release; then + COMPOSE="docker-compose" +fi +export COMPOSE + # Create default value if not set: allows compose to run in isolated namespace : "${COMPOSE_PROJECT_NAME:=$(openssl rand -hex 3)}" export COMPOSE_PROJECT_NAME @@ -117,7 +125,7 @@ finish() { # TODO: More reliable approach to this. # Give SimpleCov time to generate reports. sleep 15 - docker compose down --rmi 'local' --volumes || true + $COMPOSE down --rmi 'local' --volumes || true } # main is always called with at least the first arg. When the 2nd arg, the diff --git a/ci/test_suites/authenticators_jwt/test b/ci/test_suites/authenticators_jwt/test index e76371bf10..6f193b2912 100755 --- a/ci/test_suites/authenticators_jwt/test +++ b/ci/test_suites/authenticators_jwt/test @@ -10,14 +10,14 @@ source "./oauth/keycloak/keycloak_functions.sh" function main() { local parallel_services read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" - docker compose up --no-deps -d "${parallel_services[@]}" jwks jwks_py keycloak + $COMPOSE up --no-deps -d "${parallel_services[@]}" jwks jwks_py keycloak wait_for_keycloak_server create_keycloak_users fetch_keycloak_certificate echo "Configure jwks provider" - docker compose exec -T jwks "${JWKS_CREATE_CERTIFICATE_SCRIPT_PATH}" + $COMPOSE exec -T jwks "${JWKS_CREATE_CERTIFICATE_SCRIPT_PATH}" additional_services='jwks jwks_py keycloak' _run_cucumber_tests authenticators_jwt "$additional_services" \ diff --git a/ci/test_suites/authenticators_oidc/test b/ci/test_suites/authenticators_oidc/test index 85823c900c..2f1195e1d0 100755 --- a/ci/test_suites/authenticators_oidc/test +++ b/ci/test_suites/authenticators_oidc/test @@ -17,7 +17,7 @@ function _hydrate_all_env_args() { set -o pipefail # Note: This prints all lines that look like: # KEYCLOAK_XXX=someval - docker compose exec -T "${KEYCLOAK_SERVICE_NAME}" printenv | awk '/KEYCLOAK/' + $COMPOSE exec -T "${KEYCLOAK_SERVICE_NAME}" printenv | awk '/KEYCLOAK/' ) # shellcheck disable=SC2034 @@ -38,7 +38,7 @@ function _hydrate_all_env_args() { function main() { local parallel_services read -ra parallel_services <<< "$(get_parallel_services 'conjur pg')" - docker compose up --no-deps -d "${parallel_services[@]}" keycloak + $COMPOSE up --no-deps -d "${parallel_services[@]}" keycloak # We also run an ldap-server container for testing the OIDC & LDAP combined # use-case. We can't run this use-case in a separate Jenkins step because diff --git a/ci/test_suites/rspec/test b/ci/test_suites/rspec/test index 49eee55cea..f66417a88b 100755 --- a/ci/test_suites/rspec/test +++ b/ci/test_suites/rspec/test @@ -6,13 +6,13 @@ set -e # shellcheck disable=SC1091 source "./shared.sh" -docker compose up --no-deps -d pg +$COMPOSE up --no-deps -d pg _wait_for_pg pg # Note: The nested, escaped double quotes are needed in case $REPORT_ROOT # ever changes to a path containing a space. -docker compose run -T --rm --no-deps cucumber -ec " +$COMPOSE run -T --rm --no-deps cucumber -ec " bundle exec rake db:migrate rm -rf \"$REPORT_ROOT/spec/reports\" diff --git a/ci/test_suites/rspec_audit/test b/ci/test_suites/rspec_audit/test index b6ec77c689..4b33918f54 100755 --- a/ci/test_suites/rspec_audit/test +++ b/ci/test_suites/rspec_audit/test @@ -7,7 +7,7 @@ set -e source "./shared.sh" # Start Conjur with the audit database -docker compose up --no-deps -d audit pg +$COMPOSE up --no-deps -d audit pg _wait_for_pg audit @@ -15,7 +15,7 @@ _wait_for_pg audit # $REPORT_ROOT but not for the 2nd one where it appears in the variable # assignment. AUDIT_DATABASE_URL=postgres://postgres@audit/postgres \ - docker compose run \ + $COMPOSE run \ -T --rm --no-deps --workdir=/src/conjur-server cucumber -ec " pwd ci/rspec-audit/migratedb From 63e95b4cdba47a4dbb16c51a5140ad086d8d0897 Mon Sep 17 00:00:00 2001 From: "sofia.dimant" Date: Wed, 4 Oct 2023 09:53:03 +0300 Subject: [PATCH 145/665] Edit CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11436f97d9..c620cf7228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.0.9-cloud] - 2023-10-08 +## [1.0.10-cloud] - 2023-10-22 + +## [1.0.9-cloud] - 2023-10-15 ### Added - Add feature flag endpoint From b3400f071de54c4c0a199dd150427994162df6b3 Mon Sep 17 00:00:00 2001 From: ygeva Date: Tue, 3 Oct 2023 17:07:24 +0300 Subject: [PATCH 146/665] Add telemetry logs for issuer and ephemeral variable --- CHANGELOG.md | 2 ++ app/controllers/issuers_controller.rb | 6 ++++-- app/controllers/policies_controller.rb | 11 ++++++++++- app/domain/logs.rb | 14 +++++++++++++- app/models/audit/event/policy.rb | 8 ++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c620cf7228..3e04970c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. (and update the corresponding date), or add a new version. ## [1.0.10-cloud] - 2023-10-22 +### Added +- Telemetry logs for ephemeral secrets ## [1.0.9-cloud] - 2023-10-15 ### Added diff --git a/app/controllers/issuers_controller.rb b/app/controllers/issuers_controller.rb index d096b41ebe..95a7141864 100644 --- a/app/controllers/issuers_controller.rb +++ b/app/controllers/issuers_controller.rb @@ -40,7 +40,7 @@ def create create_issuer_policy({ "id" => params[:id] }) issuer.save issuer_audit_success(issuer.account, issuer.issuer_id, "add") - + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("create", issuer.account, issuer.issuer_id, request.ip)) render(json: issuer.as_json, status: :created) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("POST issuers/#{params[:account]}")) @@ -82,6 +82,7 @@ def delete deleted_variables = issuer.delete_issuer_variables delete_issuer_policy({ "id" => params[:identifier] }) issuer_audit_success(issuer.account, issuer.issuer_id, "remove") + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("delete", issuer.account, issuer.issuer_id, request.ip)) issuer_variables_audit_delete(issuer.account, issuer.issuer_id, deleted_variables) head :ok else @@ -110,9 +111,9 @@ def get issuer = get_issuer_from_db(params[:account], params[:identifier]) if issuer issuer_audit_success(issuer.account, issuer.issuer_id, "fetch") + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("fetch", issuer.account, issuer.issuer_id, request.ip)) render(json: issuer.as_json, status: :ok) else - # issuer_audit_failure(issuer.account, issuer.issuer_id, "get", ISSUER_NOT_FOUND) raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) end @@ -140,6 +141,7 @@ def list results.push(item.as_json_for_list) end issuer_audit_success(params[:account], "*", "list") + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("list", params[:account], "*", request.ip)) render(json: { issuers: results }, status: :ok) logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}")) diff --git a/app/controllers/policies_controller.rb b/app/controllers/policies_controller.rb index 9f0710a266..bfe61b2b8d 100644 --- a/app/controllers/policies_controller.rb +++ b/app/controllers/policies_controller.rb @@ -42,7 +42,6 @@ def find_or_create_root_policy def load_policy(action, loader_class, delete_permitted) authorize(action) - policy = save_submitted_policy(delete_permitted: delete_permitted) loaded_policy = loader_class.from_policy(policy) created_roles = perform(loaded_policy) @@ -60,6 +59,16 @@ def load_policy(action, loader_class, delete_permitted) def audit_success(policy) policy.policy_log.lazy.map(&:to_audit_event).each do |event| Audit.logger.log(event) + log_ephemeral_variable(event) + end + end + + def log_ephemeral_variable(audit_event) + if not audit_event.subject.is_a?(Audit::Subject::Resource) + return + end + if audit_event.subject.to_h[:resource].include?("variable:data/ephemerals") + logger.info(LogMessages::Ephemeral::EphemeralVariableTelemetry.new(audit_event.operation, audit_event.subject.to_h[:resource], request.ip)) end end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 3fafa69f1a..4c72d8db1a 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -68,6 +68,13 @@ module Edge ) end + module Ephemeral + EphemeralVariableTelemetry = ::Util::TrackableLogMessageClass.new( + msg: "{0} ephemeral variable '{1}' for {2} at #{Time.now}", + code: "CONJ00168I" + ) + end + module Authentication LoginError = ::Util::TrackableErrorClass.new( @@ -809,7 +816,12 @@ module Issuers msg: "Action {0} is not allowed on the issuers endpoint", code: "CONJ00159E" ) - + + TelemetryIssuerLog = ::Util::TrackableLogMessageClass.new( + msg: "{0} issuer '{1}/issuers/{2}' for {3} at #{Time.now}", + code: "CONJ00167I" + ) + end module Secrets diff --git a/app/models/audit/event/policy.rb b/app/models/audit/event/policy.rb index 658feed5b0..3c1e6f94cb 100644 --- a/app/models/audit/event/policy.rb +++ b/app/models/audit/event/policy.rb @@ -85,6 +85,14 @@ def facility Syslog::LOG_AUTHPRIV end + def subject + @subject + end + + def operation + @operation + end + private def success_message From ab267d625aff4f0a5803fb616d609ad0c1512a80 Mon Sep 17 00:00:00 2001 From: egvili Date: Tue, 3 Oct 2023 15:21:53 +0300 Subject: [PATCH 147/665] API key Creation conditionally --- app/models/credentials.rb | 6 ++-- app/models/role.rb | 8 +++++ .../authn_ldap/authenticator_spec.rb | 6 +++- spec/models/host_factory_spec.rb | 31 +++++++++++++++++++ spec/models/role_spec.rb | 27 ++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/app/models/credentials.rb b/app/models/credentials.rb index 0ce92f9658..da8a306890 100644 --- a/app/models/credentials.rb +++ b/app/models/credentials.rb @@ -73,7 +73,7 @@ def valid_api_key? key def validate super - validates_presence([ :api_key ]) + validates_presence([ :api_key ]) if self.role.api_key_expected? # We intentionally don't validate when there is no password # See flow in Account.create @@ -89,11 +89,11 @@ def validate def before_validation super - self.api_key ||= self.class.random_api_key + self.api_key ||= self.class.random_api_key if self.role.api_key_expected? end def rotate_api_key - self.api_key = self.class.random_api_key + self.api_key = self.class.random_api_key if self.role.api_key_expected? end private diff --git a/app/models/role.rb b/app/models/role.rb index bce7506256..3dc8f74fc8 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -121,6 +121,8 @@ def restricted_to=(restricted_to) end def api_key + return nil unless api_key_expected? + unless self.credentials _, kind, id = self.id.split(":", 3) allowed_kind = %w[user host deputy].member?(kind) @@ -132,6 +134,12 @@ def api_key self.credentials.api_key end + def api_key_expected? + self.kind == 'user' || + Rails.application.config.conjur_config.authn_api_key_default || + self.annotations.any? { |a| a.name == 'authn/api-key' && a.value.downcase == 'true' } + end + def login self.class.username_from_roleid(role_id) end diff --git a/spec/app/domain/authentication/authn_ldap/authenticator_spec.rb b/spec/app/domain/authentication/authn_ldap/authenticator_spec.rb index 94d70def4e..be2c739e32 100644 --- a/spec/app/domain/authentication/authn_ldap/authenticator_spec.rb +++ b/spec/app/domain/authentication/authn_ldap/authenticator_spec.rb @@ -28,7 +28,11 @@ # Assume credentials will exist allow(::Credentials) .to receive(:[]) - .and_return(Credentials.new.tap(&:rotate_api_key)) + .and_return(Credentials.new.tap do |cred| + cred.role_id = 1234 + cred.role = Role.new(role_id: cred.role_id) + cred.rotate_api_key + end) end context "as user alice" do diff --git a/spec/models/host_factory_spec.rb b/spec/models/host_factory_spec.rb index 1a412aba89..953063950d 100644 --- a/spec/models/host_factory_spec.rb +++ b/spec/models/host_factory_spec.rb @@ -134,6 +134,37 @@ it { expect { host_builder.create_host }.to raise_error } end end + + context "Without validation API key is created as expected" do + before do + allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(false) + allow_any_instance_of(Loader::Types::Host).to receive(:future_api_key_auth_will_fail?).and_return(false) + end + + context 'when creating host with api-key annotation true' do + let(:options) { {annotations: {'authn/api-key' => 'true'}} } + it { expect(host_builder.create_host[1]).not_to be_nil } # create_host returns [host, api_key] + end + + context 'when creating host with api-key annotation true' do + let(:options) { {annotations: {'authn/api-key' => 'TRUE'}} } + it { expect(host_builder.create_host[1]).not_to be_nil } # create_host returns [host, api_key] + end + + context 'when creating host with api-key annotation false' do + let(:options) { {annotations: {'authn/api-key' => false}} } + it { expect(host_builder.create_host[1]).to be_nil } # create_host returns [host, api_key] + end + + context 'when creating host with api-key annotation False capital' do + let(:options) { {annotations: {'authn/api-key' => "FALSE"}} } + it { expect(host_builder.create_host[1]).to be_nil } # create_host returns [host, api_key] + end + + context 'when creating host without api-key annotation' do + it { expect(host_builder.create_host[1]).to be_nil } # create_host returns [host, api_key] + end + end end end end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb index e427f39ad0..05af3c4900 100644 --- a/spec/models/role_spec.rb +++ b/spec/models/role_spec.rb @@ -37,4 +37,31 @@ it "can find by login" do expect(Role.by_login(login, account: 'rspec')).to eq(the_user) end + + context "Role has API key per annotation" do + before do + allow(Rails.application.config.conjur_config).to receive(:authn_api_key_default).and_return(false) + end + + subject(:role) { Role.create(role_id: "rspec:host:#{login}") } + + it "has API key when annotation is set to true" do + allow(subject).to receive(:annotations).and_return([Annotation.new(name: "authn/api-key", value: "true")]) + expect(subject.api_key).to be_present + end + + it "has API key when annotation is set to false" do + allow(subject).to receive(:annotations).and_return([Annotation.new(name: "authn/api-key", value: "false")]) + expect(subject.api_key).to be_nil + end + + it "has API key when annotation is set to blabla" do + allow(subject).to receive(:annotations).and_return([Annotation.new(name: "authn/api-key", value: "blabla")]) + expect(subject.api_key).to be_nil + end + + it "has API key when annotation is not set" do + expect(subject.api_key).to be_nil + end + end end From b38be9b0eab1e968a07a8e3347ab85d9af2008dd Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Fri, 28 Jul 2023 13:25:11 -0400 Subject: [PATCH 148/665] Cukes: use CONJUR_APPLIANCE_URL before http://conjur (cherry picked from commit 7f2be27ede77e26f61d8ab4d18995e859d4d08db) --- config/environments/test.rb | 2 +- cucumber/_authenticators_common/features/support/env.rb | 2 +- cucumber/_authenticators_common/features/support/hooks.rb | 2 +- cucumber/api/features/support/env.rb | 2 +- cucumber/api/features/support/hooks.rb | 2 +- cucumber/authenticators/features/support/hooks.rb | 2 +- cucumber/policy/features/support/env.rb | 2 +- cucumber/policy/features/support/hooks.rb | 2 +- cucumber/rotators/features/support/env.rb | 2 +- engines/conjur_audit/config/routes.rb | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/config/environments/test.rb b/config/environments/test.rb index b8c1ff3fd6..f889b55552 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -4,7 +4,7 @@ require 'test/audit_sink' parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/_authenticators_common/features/support/env.rb b/cucumber/_authenticators_common/features/support/env.rb index 35f864e9f1..7b45442a89 100644 --- a/cucumber/_authenticators_common/features/support/env.rb +++ b/cucumber/_authenticators_common/features/support/env.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/_authenticators_common/features/support/hooks.rb b/cucumber/_authenticators_common/features/support/hooks.rb index 7da7be1ebf..9076879fa8 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -11,7 +11,7 @@ # run independently. Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index 01a666eed7..13029e37e9 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -5,7 +5,7 @@ ENV['CONJUR_LOG_LEVEL'] ||= 'debug' parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index ab994604af..fce17e6901 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -12,7 +12,7 @@ # run independently. Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index 4d29131b5d..dcc031be7d 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -8,7 +8,7 @@ require 'cucumber/_common/slosilo_helper' Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/policy/features/support/env.rb b/cucumber/policy/features/support/env.rb index 179752d970..ad3eef79ca 100644 --- a/cucumber/policy/features/support/env.rb +++ b/cucumber/policy/features/support/env.rb @@ -6,7 +6,7 @@ require 'rest-client' parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/policy/features/support/hooks.rb b/cucumber/policy/features/support/hooks.rb index f550843861..33ca2c5f4e 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -20,7 +20,7 @@ # run independently. Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/rotators/features/support/env.rb b/cucumber/rotators/features/support/env.rb index 74ec8c5016..245a630c85 100644 --- a/cucumber/rotators/features/support/env.rb +++ b/cucumber/rotators/features/support/env.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/engines/conjur_audit/config/routes.rb b/engines/conjur_audit/config/routes.rb index 70e6a08710..cc43cf2c63 100644 --- a/engines/conjur_audit/config/routes.rb +++ b/engines/conjur_audit/config/routes.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] From 4f7c2a03669ea97365766d9c18122028e64c7f76 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Mon, 9 Oct 2023 17:21:15 +0300 Subject: [PATCH 149/665] ONYX-45969: Host authenticate without api key annotation should fail on correct error --- app/models/credentials.rb | 5 ++++- cucumber/api/features/authenticate.feature | 16 ++++++++++++++ .../features/step_definitions/user_steps.rb | 6 ++++++ cucumber/api/features/support/rest_helpers.rb | 21 +++++++++++++++---- spec/models/credentials_spec.rb | 12 ++++++++++- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/models/credentials.rb b/app/models/credentials.rb index da8a306890..b8f0dc0f4a 100644 --- a/app/models/credentials.rb +++ b/app/models/credentials.rb @@ -66,7 +66,10 @@ def valid_password? pwd def valid_api_key? key return false if expired? - + if api_key.nil? + Rails.logger.warn("No api key exists for this role") + return false + end key && ActiveSupport::SecurityUtils.secure_compare(key, api_key) end diff --git a/cucumber/api/features/authenticate.feature b/cucumber/api/features/authenticate.feature index 91ce2a164b..14681acaa9 100644 --- a/cucumber/api/features/authenticate.feature +++ b/cucumber/api/features/authenticate.feature @@ -9,6 +9,7 @@ Feature: Exchange a role's API key for a signed authentication token Background: Given I create a new user "alice" And I have host "app" + And I have host "appNoApiKey" without api key @smoke Scenario: A role's API key can be used to authenticate @@ -164,6 +165,21 @@ Feature: Exchange a role's API key for a signed authentication token cucumber:host:app failed to authenticate with authenticator authn """ + #@negative @acceptance + #Scenario: Attempting to use host API key to authenticate host without api key result in 401 error + # Given I save my place in the audit log file for remote + # When I POST "/authn/cucumber/host%2FappNoApiKey/authenticate" with plain text body "" + # Then the HTTP response status code is 401 + # And there is an audit record matching: + # """ + # <84>1 * * conjur * authn + # [subject@43868 role="cucumber:host:appNoApiKey"] + # [auth@43868 user="cucumber:host:appNoApiKey" authenticator="authn" service="cucumber:webservice:conjur/authn"] + # [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + # [action@43868 result="failure" operation="authenticate"] + # cucumber:host:appNoApiKey failed to authenticate with authenticator authn + # """ + @negative @acceptance Scenario: Attempting to use an invalid API key to authenticate with Accept-Encoding base64 result in 401 error Given I save my place in the audit log file for remote diff --git a/cucumber/api/features/step_definitions/user_steps.rb b/cucumber/api/features/step_definitions/user_steps.rb index 086b101908..254c621cce 100644 --- a/cucumber/api/features/step_definitions/user_steps.rb +++ b/cucumber/api/features/step_definitions/user_steps.rb @@ -24,6 +24,12 @@ end end +Given("I have host {string} without api key") do |login| + unless host_exists?(login) + create_host login, @current_user || admin_user, false + end +end + Given("I create a new admin-owned user {string}") do |login| create_user login, admin_user end diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index 50cb6c7040..ddb111efd3 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -250,17 +250,27 @@ def create_user login, owner end # Create a regular host, owned by the admin user - def create_host login, owner + def create_host login, owner, api_key_annotation=true roleid = "cucumber:host:#{login}" - create_role(roleid, owner) + create_role(roleid, owner, api_key_annotation) end - def create_role roleid, owner + def create_role roleid, owner, api_key_annotation=false return if roles[roleid] + resource = Resource.create(resource_id: roleid, owner: owner) + # If needed add the annotation to create api key + puts "roleid:#{roleid}; api_key:#{api_key_annotation}" + if api_key_annotation + puts "adding annotation" + resource.annotations << + Annotation.create(resource: resource, + name: "authn/api-key", + value: "true") + end + Role.create(role_id: roleid).tap do |role| Credentials[role: role] || Credentials.new(role: role).save(raise_on_save_failure: true) - Resource.create(resource_id: roleid, owner: owner) roles[roleid] = role end end @@ -362,6 +372,9 @@ def denormalize str patterns["#{key}_api_key"] = val.credentials.api_key end patterns.each do |key, val| + if val.nil? + val = "" + end str.gsub!(":#{key}", val) str.gsub!("@#{key}@", val) str.gsub!(CGI.escape(":#{key}"), CGI.escape(val)) diff --git a/spec/models/credentials_spec.rb b/spec/models/credentials_spec.rb index 7963c1166a..d060d4eda5 100644 --- a/spec/models/credentials_spec.rb +++ b/spec/models/credentials_spec.rb @@ -136,7 +136,17 @@ expect(credentials.valid_password?(password)).to be_truthy end end - + + describe "role without api key" do + before { + credentials.api_key = nil + credentials.save + } + it "doesn't have a valid API key" do + expect(credentials.valid_api_key?("")).to be(false) + end + end + describe "with expiration" do let(:now) { Time.now } let(:past) { now - 1.second } From 90279d53a425f0b42b178c63db024cf7643fdc42 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Tue, 10 Oct 2023 14:51:05 +0300 Subject: [PATCH 150/665] ONYX-45969: Fix for Conjur dependency issue - PR #2983 --- gems/policy-parser/Dockerfile.test | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gems/policy-parser/Dockerfile.test b/gems/policy-parser/Dockerfile.test index c5ac281ed0..26282b5b2a 100644 --- a/gems/policy-parser/Dockerfile.test +++ b/gems/policy-parser/Dockerfile.test @@ -7,6 +7,8 @@ COPY Gemfile Gemfile COPY conjur-policy-parser.gemspec conjur-policy-parser.gemspec COPY lib/conjur-policy-parser-version.rb lib/conjur-policy-parser-version.rb +RUN apt-get update && apt-get install -y libffi-dev build-essential libyaml-dev + # Make sure the expected version of Bundler is available ENV BUNDLER_VERSION=2.2.33 RUN gem install bundler -v ${BUNDLER_VERSION} && \ From d76be7a660738605745304add24fa4e76b67e68b Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Fri, 28 Jul 2023 13:25:11 -0400 Subject: [PATCH 151/665] Cukes: use CONJUR_APPLIANCE_URL before http://conjur (cherry picked from commit 7f2be27ede77e26f61d8ab4d18995e859d4d08db) --- config/environments/test.rb | 2 +- cucumber/_authenticators_common/features/support/env.rb | 2 +- cucumber/_authenticators_common/features/support/hooks.rb | 2 +- cucumber/api/features/support/env.rb | 2 +- cucumber/api/features/support/hooks.rb | 2 +- cucumber/authenticators/features/support/hooks.rb | 2 +- cucumber/policy/features/support/env.rb | 2 +- cucumber/policy/features/support/hooks.rb | 2 +- cucumber/rotators/features/support/env.rb | 2 +- engines/conjur_audit/config/routes.rb | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/config/environments/test.rb b/config/environments/test.rb index b8c1ff3fd6..f889b55552 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -4,7 +4,7 @@ require 'test/audit_sink' parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/_authenticators_common/features/support/env.rb b/cucumber/_authenticators_common/features/support/env.rb index 35f864e9f1..7b45442a89 100644 --- a/cucumber/_authenticators_common/features/support/env.rb +++ b/cucumber/_authenticators_common/features/support/env.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/_authenticators_common/features/support/hooks.rb b/cucumber/_authenticators_common/features/support/hooks.rb index 7da7be1ebf..9076879fa8 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -11,7 +11,7 @@ # run independently. Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index 01a666eed7..13029e37e9 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -5,7 +5,7 @@ ENV['CONJUR_LOG_LEVEL'] ||= 'debug' parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index ab994604af..fce17e6901 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -12,7 +12,7 @@ # run independently. Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] parallel_cuke_vars['AUTHN_LOCAL_SOCKET'] = ENV["AUTHN_LOCAL_SOCKET#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index 4d29131b5d..dcc031be7d 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -8,7 +8,7 @@ require 'cucumber/_common/slosilo_helper' Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/policy/features/support/env.rb b/cucumber/policy/features/support/env.rb index 179752d970..ad3eef79ca 100644 --- a/cucumber/policy/features/support/env.rb +++ b/cucumber/policy/features/support/env.rb @@ -6,7 +6,7 @@ require 'rest-client' parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/policy/features/support/hooks.rb b/cucumber/policy/features/support/hooks.rb index f550843861..33ca2c5f4e 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -20,7 +20,7 @@ # run independently. Before do parallel_cuke_vars = {} - parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" + parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/cucumber/rotators/features/support/env.rb b/cucumber/rotators/features/support/env.rb index 74ec8c5016..245a630c85 100644 --- a/cucumber/rotators/features/support/env.rb +++ b/cucumber/rotators/features/support/env.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] diff --git a/engines/conjur_audit/config/routes.rb b/engines/conjur_audit/config/routes.rb index 70e6a08710..cc43cf2c63 100644 --- a/engines/conjur_audit/config/routes.rb +++ b/engines/conjur_audit/config/routes.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true parallel_cuke_vars = {} -parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = "http://conjur#{ENV['TEST_ENV_NUMBER']}" +parallel_cuke_vars['CONJUR_APPLIANCE_URL'] = ENV.fetch('CONJUR_APPLIANCE_URL', "http://conjur#{ENV['TEST_ENV_NUMBER']}") parallel_cuke_vars['DATABASE_URL'] = "postgres://postgres@pg#{ENV['TEST_ENV_NUMBER']}/postgres" parallel_cuke_vars['CONJUR_AUTHN_API_KEY'] = ENV["CONJUR_AUTHN_API_KEY#{ENV['TEST_ENV_NUMBER']}"] From da240d0669fcb7326906c82f44c4e533b41262c5 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Tue, 10 Oct 2023 19:21:04 +0300 Subject: [PATCH 152/665] ONYX-45969: Fix cucumber tests --- .../features/edge/internal/edge_hosts.feature | 2 +- cucumber/api/features/support/rest_helpers.rb | 20 +++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/cucumber/api/features/edge/internal/edge_hosts.feature b/cucumber/api/features/edge/internal/edge_hosts.feature index 4555d393c6..088c50a57a 100644 --- a/cucumber/api/features/edge/internal/edge_hosts.feature +++ b/cucumber/api/features/edge/internal/edge_hosts.feature @@ -60,7 +60,7 @@ Feature: Fetching host from edge endpoint """ And the JSON at "hosts/1/annotations" should be: """ - [] + [{"name": "authn/api-key", "value": "true"}] """ @acceptance diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index ddb111efd3..bf2187b188 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -258,18 +258,16 @@ def create_host login, owner, api_key_annotation=true def create_role roleid, owner, api_key_annotation=false return if roles[roleid] - resource = Resource.create(resource_id: roleid, owner: owner) - # If needed add the annotation to create api key - puts "roleid:#{roleid}; api_key:#{api_key_annotation}" - if api_key_annotation - puts "adding annotation" - resource.annotations << - Annotation.create(resource: resource, - name: "authn/api-key", - value: "true") - end - + #resource = Resource.create(resource_id: roleid, owner: owner) Role.create(role_id: roleid).tap do |role| + resource = Resource.create(resource_id: roleid, owner: owner) + # If needed add the annotation to create api key + if api_key_annotation + role.annotations << + Annotation.create(resource: resource, + name: "authn/api-key", + value: "true") + end Credentials[role: role] || Credentials.new(role: role).save(raise_on_save_failure: true) roles[roleid] = role end From b3c640664c2dd9bab9be37bfd0136ba6d226fed6 Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Wed, 11 Oct 2023 14:56:10 +0300 Subject: [PATCH 153/665] ONYX-45974: Edge hosts retrieve returns empty api key and salt for hosts without api key defined --- app/domain/edge_logic/replication_handler.rb | 22 ++++++++++++------- .../features/edge/internal/edge_hosts.feature | 6 ++++- .../internal/edge_hosts_controller_spec.rb | 15 ++++++++----- spec/spec_helper.rb | 20 +++++++++++++++++ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/app/domain/edge_logic/replication_handler.rb b/app/domain/edge_logic/replication_handler.rb index cab830ba41..a8e3ca6a48 100644 --- a/app/domain/edge_logic/replication_handler.rb +++ b/app/domain/edge_logic/replication_handler.rb @@ -5,14 +5,20 @@ def replicate_hosts(scope) roles_with_creds = scope.eager(:credentials) hosts = Role.roles_with_annotations(roles_with_creds).all hosts.each do |host| - hostToReturn = {} - hostToReturn[:id] = host[:role_id] - salt = OpenSSL::Random.random_bytes(32) - hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) - hostToReturn[:salt] = Base64.strict_encode64(salt) - hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } - hostToReturn[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) - results << hostToReturn + host_to_return = {} + host_to_return[:id] = host[:role_id] + host_api_key = host.api_key + if host_api_key.nil? + host_to_return[:api_key] = "" + host_to_return[:salt] = "" + else + salt = OpenSSL::Random.random_bytes(32) + host_to_return[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + host_to_return[:salt] = Base64.strict_encode64(salt) + end + host_to_return[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + host_to_return[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) + results << host_to_return end results end diff --git a/cucumber/api/features/edge/internal/edge_hosts.feature b/cucumber/api/features/edge/internal/edge_hosts.feature index 088c50a57a..6f845096fb 100644 --- a/cucumber/api/features/edge/internal/edge_hosts.feature +++ b/cucumber/api/features/edge/internal/edge_hosts.feature @@ -4,7 +4,7 @@ Feature: Fetching host from edge endpoint Background: Given I create a new user "some_user" And I have host "data/some_host2" - And I have host "data/some_host3" + And I have host "data/some_host3" without api key And I have host "data/some_host4" And I have host "data/some_host5" And I have host "other_host1" @@ -62,6 +62,10 @@ Feature: Fetching host from edge endpoint """ [{"name": "authn/api-key", "value": "true"}] """ + #And the JSON at "hosts/2/annotations" should be: + #""" + #[] + #""" @acceptance Scenario: Fetching hosts with parameters diff --git a/spec/controllers/edge/internal/edge_hosts_controller_spec.rb b/spec/controllers/edge/internal/edge_hosts_controller_spec.rb index b7382fc574..8527762b87 100644 --- a/spec/controllers/edge/internal/edge_hosts_controller_spec.rb +++ b/spec/controllers/edge/internal/edge_hosts_controller_spec.rb @@ -4,6 +4,8 @@ let(:account) { "rspec" } let(:host_id) {"#{account}:host:edge/edge-1234/edge-host-1234"} let(:other_host_id) {"#{account}:host:data/other"} + let(:no_apikey_host_id) {"#{account}:host:data/noapikey"} + let(:admin_user) { Role.find_or_create(role_id: 'rspec:user:admin') } let(:get_hosts) do "/edge/hosts/#{account}" @@ -11,9 +13,10 @@ before do init_slosilo_keys(account) - @current_user = Role.find_or_create(role_id: host_id) - @other_user = Role.find_or_create(role_id: other_host_id) - #@admin_user = Role.find_or_create(role_id: admin_user_id) + + @current_user = create_host(host_id, admin_user) + @other_user = create_host(other_host_id, admin_user) + @no_apikey_host = create_host_without_apikey(no_apikey_host_id, admin_user) end context "Host" do @@ -27,11 +30,13 @@ expect(response.body).to include("api_key".strip) expect(response.body).to include("salt".strip) @result = JSON.parse(response.body) - encoded_api_key = @result['hosts'][0]['api_key'] - encoded_salt = @result['hosts'][0]['salt'] + encoded_api_key = @result['hosts'][1]['api_key'] + encoded_salt = @result['hosts'][1]['salt'] salt = Base64.strict_decode64(encoded_salt) test_api_key = Base64.strict_encode64(Cryptography.hmac_api_key(@other_user.credentials.api_key, salt)) expect(test_api_key).to eq(encoded_api_key) + #expect("").to eq(@result['hosts'][0]['api_key']) + #expect("").to eq(@result['hosts'][0]['salt']) end end end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7cd2c2950c..af17283ee1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -168,3 +168,23 @@ def token_auth_header(role:, account: 'rspec', is_user: true) { 'HTTP_AUTHORIZATION' => "Token token=\"#{base64_token}\"" } end + +def create_host(host_id, owner, api_key_annotation=true) + host_role = Role.create(role_id: host_id) + host_role.tap do |role| + resource = Resource.create(resource_id: host_id, owner: owner) + # If needed add the annotation to create api key + if api_key_annotation + role.annotations << + Annotation.create(resource: resource, + name: "authn/api-key", + value: "true") + end + Credentials[role: role] || Credentials.new(role: role).save(raise_on_save_failure: true) + end + host_role +end + +def create_host_without_apikey(host_id, owner) + create_host(host_id, owner, false) +end From 8f381bc03d677a33b9e80cc37096892ce9bf78fd Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Thu, 12 Oct 2023 15:22:37 +0300 Subject: [PATCH 154/665] ONYX-45972: Rotate api key for host without api key fails --- app/models/credentials.rb | 6 ++++- cucumber/api/features/rotate_api_key.feature | 27 ++++++++++++++++++++ spec/models/credentials_spec.rb | 6 +++++ spec/spec_helper.rb | 17 +++++++----- spec/support/authentication.rb | 19 ++++++++++---- 5 files changed, 63 insertions(+), 12 deletions(-) diff --git a/app/models/credentials.rb b/app/models/credentials.rb index b8f0dc0f4a..964317333b 100644 --- a/app/models/credentials.rb +++ b/app/models/credentials.rb @@ -96,7 +96,11 @@ def before_validation end def rotate_api_key - self.api_key = self.class.random_api_key if self.role.api_key_expected? + if self.role.api_key_expected? + self.api_key = self.class.random_api_key + else + raise Exceptions::MethodNotAllowed, "Operation is not supported for host since it does not use api-key for authentication" + end end private diff --git a/cucumber/api/features/rotate_api_key.feature b/cucumber/api/features/rotate_api_key.feature index a1ec590157..3d2120c2fe 100644 --- a/cucumber/api/features/rotate_api_key.feature +++ b/cucumber/api/features/rotate_api_key.feature @@ -11,6 +11,7 @@ Feature: Rotate the API key of a role - Bob himself - a user with update permission, "privileged_user" - a user without update permission, "unprivileged_user" + - a host with no api key, "privileged_host_without_apikey" - a host belonging to a layer with update permission, "privileged_host" - a host without update permission, "unprivileged_host" These roles attempt to rotate Bob's API key with all authentication methods @@ -25,6 +26,7 @@ Feature: Rotate the API key of a role And I create a new user "unprivileged_user" And I have host "privileged_host" And I have host "unprivileged_host" + And I have host "privileged_host_without_apikey" without api key And I am the super-user And I successfully PUT "/policies/cucumber/policy/root" with body: """ @@ -41,6 +43,7 @@ Feature: Rotate the API key of a role - !policy strict_policy - !group super_users - !host privileged_host + - !host privileged_host_without_apikey # give privileged_user update permissions over user bob - !permit @@ -90,6 +93,12 @@ Feature: Rotate the API key of a role role: !layer host_layer privilege: [ update ] resource: !user bob + + #give update permission to a host without api key + - !permit + role: !host privileged_host + privilege: [ update ] + resource: !host privileged_host_without_apikey """ And I log out @@ -307,6 +316,12 @@ Feature: Rotate the API key of a role cucumber:host:privileged_host successfully rotated their API key """ + @negative @acceptance @skip + Scenario: A Host without api key CANNOT rotate their own API key + Given I save my place in the audit log file + When I PUT "/authn/cucumber/api_key?role=host:privileged_host_without_apikey" with username "host/privileged_host_without_apikey" and password ":cucumber:host:api_key" + Then the HTTP response status code is 401 + @negative @acceptance Scenario: A Host CANNOT rotate their own API key using an access token Given I login as "host/privileged_host" @@ -342,6 +357,18 @@ Feature: Rotate the API key of a role cucumber:host:privileged_host successfully rotated the api key for cucumber:user:bob """ + # A host with update permission rotating host without api key + @negative @acceptance @skip + Scenario: A Host with update privilege CANNOT rotate host API key that doesn't have api key + Given I login as "host/privileged_host" + And I save my place in the audit log file + When I PUT "/authn/cucumber/api_key?role=host:privileged_host_without_apikey" + Then the HTTP response status code is 405 + And The following appears in the audit log after my savepoint: + """ + Operation is not supported for host since it does not use api-key for authentication + """ + @negative @acceptance Scenario: A Host with update privilege CANNOT rotate Bob's API key with their own API key Given I save my place in the audit log file diff --git a/spec/models/credentials_spec.rb b/spec/models/credentials_spec.rb index d060d4eda5..81ddc3a399 100644 --- a/spec/models/credentials_spec.rb +++ b/spec/models/credentials_spec.rb @@ -4,6 +4,7 @@ describe Credentials, :type => :model do include_context "create user" + include_context "create host" let(:login) { "u-#{random_hex}" } let(:credentials) { the_user.credentials } @@ -44,6 +45,11 @@ credentials.rotate_api_key expect(credentials.api_key).to_not eq(api_key) end + #it "Fails changing the API key for host without api key" do + # expect { host_without_apikey.credentials.rotate_api_key }.to raise_error(Exceptions::MethodNotAllowed) do |e| + # expect(e.message).to eq("Operation is not supported for host since it does not use api-key for authentication") + # end + #end end describe '#password=' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index af17283ee1..129267b499 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -174,12 +174,7 @@ def create_host(host_id, owner, api_key_annotation=true) host_role.tap do |role| resource = Resource.create(resource_id: host_id, owner: owner) # If needed add the annotation to create api key - if api_key_annotation - role.annotations << - Annotation.create(resource: resource, - name: "authn/api-key", - value: "true") - end + add_api_key_annotation(resource, role, api_key_annotation) Credentials[role: role] || Credentials.new(role: role).save(raise_on_save_failure: true) end host_role @@ -188,3 +183,13 @@ def create_host(host_id, owner, api_key_annotation=true) def create_host_without_apikey(host_id, owner) create_host(host_id, owner, false) end + +def add_api_key_annotation(resource, role, api_key_annotation) + # If needed add the annotation to create api key + if api_key_annotation + role.annotations << + Annotation.create(resource: resource, + name: "authn/api-key", + value: "true") + end +end diff --git a/spec/support/authentication.rb b/spec/support/authentication.rb index 74aaac29c0..624c9dd617 100644 --- a/spec/support/authentication.rb +++ b/spec/support/authentication.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'spec_helper' shared_context "existing account" do let(:validate_account_exists) { double("ValidateAccountExists") } @@ -69,23 +70,31 @@ def create_user(login) end shared_context "create host" do - def create_host(host_login) + def create_host(host_login, api_key_annotation=true) id = "rspec:host:#{host_login}" host_role = Role.create(role_id: id).tap do |role| + Resource.create(resource_id: id, owner_id: id).tap do |resource| + # If needed add the annotation to create api key + add_api_key_annotation(resource, role, api_key_annotation) + + resource.reload + host_role.reload unless host_role.nil? + end + options = { role: role } Credentials.create(options) role.reload end - Resource.create(resource_id: id, owner_id: id).tap do |resource| - resource.reload - host_role.reload - end return host_role end let(:host_login) { "default-host-login" } let(:host_api_key) { the_host.credentials.api_key } + let(:host_without_apikey_login) { "host-without-apikey" } + let!(:host_without_apikey) { + create_host(host_without_apikey_login, false) + } end shared_context "host authenticate Basic" do From 2db2918afe6269822b6bf433e5923c4155af49e8 Mon Sep 17 00:00:00 2001 From: egvili Date: Wed, 11 Oct 2023 13:41:12 +0300 Subject: [PATCH 155/665] Optional API key update --- app/controllers/policies_controller.rb | 13 ++- app/controllers/workload_controller.rb | 2 +- app/controllers/wrappers/policy_wrapper.rb | 11 ++- app/domain/authentication/optional_api_key.rb | 17 ++++ app/models/credentials.rb | 10 +++ app/models/functions.rb | 66 ++++++++++++++ app/models/role.rb | 4 +- .../features/authn_optional_api_key.feature | 86 +++++++++++++++++++ .../step_definitions/request_steps.rb | 10 +++ .../features/step_definitions/user_steps.rb | 10 +++ cucumber/api/features/support/rest_helpers.rb | 3 +- ...20231005150946_optional_api_key_trigger.rb | 12 +++ .../authentication/optional_api_key_spec.rb | 20 +++++ spec/controllers/policies_controller_spec.rb | 40 ++++++--- spec/models/credentials_spec.rb | 7 ++ spec/models/host_factory_spec.rb | 19 ++-- spec/models/role_spec.rb | 8 +- 17 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 app/domain/authentication/optional_api_key.rb create mode 100644 cucumber/api/features/authn_optional_api_key.feature create mode 100644 db/migrate/20231005150946_optional_api_key_trigger.rb create mode 100644 spec/app/domain/authentication/optional_api_key_spec.rb diff --git a/app/controllers/policies_controller.rb b/app/controllers/policies_controller.rb index bfe61b2b8d..b28ff8e23d 100644 --- a/app/controllers/policies_controller.rb +++ b/app/controllers/policies_controller.rb @@ -31,7 +31,9 @@ def post def perform(policy_action) policy_action.call new_actor_roles = actor_roles(policy_action.new_roles) - create_roles(new_actor_roles) + created_roles = create_roles(new_actor_roles) + updated_roles = update_roles + created_roles.merge(updated_roles) end def find_or_create_root_policy @@ -124,4 +126,13 @@ def create_roles(actor_roles) memo[role_id] = { id: role_id, api_key: credentials.api_key } end end + + # If annotation authn/api-key changed from false to true during policy load, + # the DB trigger set it to APIKEY. We need to update the api_key to a real one. + def update_roles + Credentials.where(api_key: 'APIKEY').each_with_object({}) do |credentials, memo| + role_id = credentials.role_id + memo[role_id] = { id: role_id, api_key: credentials.api_key } + end + end end diff --git a/app/controllers/workload_controller.rb b/app/controllers/workload_controller.rb index acbfb94b7f..a4c96162c7 100644 --- a/app/controllers/workload_controller.rb +++ b/app/controllers/workload_controller.rb @@ -48,7 +48,7 @@ def post private def validateId(name) - validate_params({"workload_name" => name}, ->(k,v){ + validate_params({"id" => name}, ->(k,v){ !v.nil? && !v.empty? && v.match?(/^[a-zA-Z0-9_-]+$/) && string_length_validator(3, 60).call(k, v) }) diff --git a/app/controllers/wrappers/policy_wrapper.rb b/app/controllers/wrappers/policy_wrapper.rb index 402bf14871..25e0b2c09f 100644 --- a/app/controllers/wrappers/policy_wrapper.rb +++ b/app/controllers/wrappers/policy_wrapper.rb @@ -49,7 +49,9 @@ def save_submitted_policy(delete_permitted:, resource:) def perform(policy_action) policy_action.call new_actor_roles = actor_roles(policy_action.new_roles) - create_roles(new_actor_roles) + created_roles = create_roles(new_actor_roles) + updated_roles = update_roles + created_roles.merge(updated_roles) end def actor_roles(roles) @@ -65,6 +67,13 @@ def create_roles(actor_roles) memo[role_id] = { id: role_id, api_key: credentials.api_key } end end + + def update_roles + Credentials.where(api_key: 'APIKEY').each_with_object({}) do |credentials, memo| + role_id = credentials.role_id + memo[role_id] = { id: role_id, api_key: credentials.api_key } + end + end end def concurrent_load(_exception) diff --git a/app/domain/authentication/optional_api_key.rb b/app/domain/authentication/optional_api_key.rb new file mode 100644 index 0000000000..5159a56727 --- /dev/null +++ b/app/domain/authentication/optional_api_key.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Authentication + module OptionalApiKey + + AUTHN_ANNOTATION = 'authn/api-key' + + def annotation_relevant?(annotation) + annotation.name == AUTHN_ANNOTATION + end + + def annotation_true?(annotation) + annotation_relevant?(annotation) && annotation.value.downcase == 'true' + end + + end +end diff --git a/app/models/credentials.rb b/app/models/credentials.rb index 964317333b..e7c2a7b3f4 100644 --- a/app/models/credentials.rb +++ b/app/models/credentials.rb @@ -95,6 +95,16 @@ def before_validation self.api_key ||= self.class.random_api_key if self.role.api_key_expected? end + def api_key + # api_key is set to 'APIKEY' by trigger in case authn/api-key is set to true + # In this case, we want to rotate the api key to a real value + if self[:api_key] == 'APIKEY' + rotate_api_key + save_changes + end + super() + end + def rotate_api_key if self.role.api_key_expected? self.api_key = self.class.random_api_key diff --git a/app/models/functions.rb b/app/models/functions.rb index 9336c63aa4..924da2fc03 100644 --- a/app/models/functions.rb +++ b/app/models/functions.rb @@ -127,5 +127,71 @@ def drop_version_trigger_sql(table) DROP FUNCTION IF EXISTS #{table}_next_version(); COUNTER_TRIGGER end + + def create_authn_ann_trigger_sql(primary_schema) + <<-ANNOTATION_SET + CREATE OR REPLACE FUNCTION insert_api_key_by_annotation() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.name = 'authn/api-key' AND NEW.value = 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = 'APIKEY' + WHERE role_id = NEW.resource_id; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql STRICT; + CREATE TRIGGER insert_api_key_by_annotation_trigger + AFTER INSERT ON annotations + FOR EACH ROW + EXECUTE FUNCTION insert_api_key_by_annotation(); + + CREATE OR REPLACE FUNCTION update_api_key_by_annotation() + RETURNS TRIGGER AS $$ + BEGIN + IF OLD.name = 'authn/api-key' AND NEW.name = 'authn/api-key' THEN + IF OLD.value = 'true' AND NEW.value != 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = NULL + WHERE role_id = OLD.resource_id; + ELSIF OLD.value != 'true' AND NEW.value = 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = 'APIKEY' + WHERE role_id = OLD.resource_id; + END IF; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql STRICT; + CREATE TRIGGER update_api_key_by_annotation_trigger + AFTER UPDATE ON annotations + FOR EACH ROW + EXECUTE FUNCTION update_api_key_by_annotation(); + + CREATE OR REPLACE FUNCTION delete_api_key_by_annotation() + RETURNS TRIGGER AS $$ + BEGIN + IF OLD.name = 'authn/api-key' AND OLD.value = 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = NULL + WHERE role_id = OLD.resource_id; + END IF; + RETURN OLD; + END; + $$ LANGUAGE plpgsql STRICT; + CREATE TRIGGER delete_api_key_by_annotation_trigger + BEFORE DELETE ON annotations + FOR EACH ROW + EXECUTE FUNCTION delete_api_key_by_annotation(); + ANNOTATION_SET + end + + def drop_authn_anno_trigger_sql + <<-ANNOTATION_SET + DROP TRIGGER IF EXISTS insert_api_key_by_annotation_trigger ON annotations; + DROP TRIGGER IF EXISTS update_api_key_by_annotation_trigger ON annotations; + DROP TRIGGER IF EXISTS delete_api_key_by_annotation_trigger ON annotations; + ANNOTATION_SET + end end end diff --git a/app/models/role.rb b/app/models/role.rb index 3dc8f74fc8..3b55971e66 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -3,6 +3,7 @@ class Role < Sequel::Model extend Forwardable include HasId + include Authentication::OptionalApiKey unrestrict_primary_key @@ -121,7 +122,6 @@ def restricted_to=(restricted_to) end def api_key - return nil unless api_key_expected? unless self.credentials _, kind, id = self.id.split(":", 3) @@ -137,7 +137,7 @@ def api_key def api_key_expected? self.kind == 'user' || Rails.application.config.conjur_config.authn_api_key_default || - self.annotations.any? { |a| a.name == 'authn/api-key' && a.value.downcase == 'true' } + self.annotations.any? { |a| annotation_true?(a) } end def login diff --git a/cucumber/api/features/authn_optional_api_key.feature b/cucumber/api/features/authn_optional_api_key.feature new file mode 100644 index 0000000000..86c4620745 --- /dev/null +++ b/cucumber/api/features/authn_optional_api_key.feature @@ -0,0 +1,86 @@ +@api @skip +Feature: API key for host is created and removed based on host's annotation + Background: + Given I am the super-user + + Scenario: Host Creation with true annotation impacts API key + Given I have host "optional" + Then the role "cucumber:host:optional" has non-empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 200 + + Scenario: Host Creation with false annotation impacts API key + Given I have host "optional" without api key + Then the role "cucumber:host:optional" has empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 401 + + Scenario: Host Creation via wrapper with true annotation impacts API key + Given I set the "Content-Type" header to "application/json" + And I successfully POST "/hosts/cucumber/root" with body: + """ + { "id": "optional", "annotations": { "authn/api-key": "true" } } + """ + Then the role "cucumber:host:optional" has non-empty API key + + Scenario: Host Creation via wrapper with false annotation impacts API key + Given I set the "Content-Type" header to "application/json" + And I successfully POST "/hosts/cucumber/root" with body: + """ + { "id": "optional", "annotations": { "authn/api-key": "false" } } + """ + Then the role "cucumber:host:optional" has empty API key + + Scenario: Host Creation via wrapper with no annotation impacts API key + Given I set the "Content-Type" header to "application/json" + And I successfully POST "/hosts/cucumber/root" with body: + """ + { "id": "optional" } + """ + Then the role "cucumber:host:optional" has empty API key + + Scenario: Only Host Annotation authn/api-key Modification impacts API key + Given I have host "optional" + Then the role "cucumber:host:optional" has non-empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 200 + When I set annotation "other-annotation" to "false" on role "cucumber:host:optional" + Then the role "cucumber:host:optional" has non-empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 200 + When I set annotation "authn/api-key" to "false" on role "cucumber:host:optional" + Then the role "cucumber:host:optional" has empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 401 + When I set annotation "other-annotation" to "true" on role "cucumber:host:optional" + Then the role "cucumber:host:optional" has empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 401 + When I set annotation "authn/api-key" to "true" on role "cucumber:host:optional" + Then the role "cucumber:host:optional" has non-empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 200 + + Scenario: Only Host Annotation authn/api-key Addition impacts API key + Given I have host "optional" without api key + Then the role "cucumber:host:optional" has empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 401 + When I set annotation "other-annotation" to "true" on role "cucumber:host:optional" + Then the role "cucumber:host:optional" has empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 401 + When I set annotation "authn/api-key" to "true" on role "cucumber:host:optional" + Then the role "cucumber:host:optional" has non-empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 200 + + Scenario: Host Annotation Removal impacts API key + Given I have host "optional" + Then the role "cucumber:host:optional" has non-empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 200 + When I remove all annotations from host "optional" + Then the role "cucumber:host:optional" has empty API key + When I POST "/authn/cucumber/host%2Foptional/authenticate" with plain text body ":cucumber:host:optional_api_key" + Then the HTTP response status code is 401 diff --git a/cucumber/api/features/step_definitions/request_steps.rb b/cucumber/api/features/step_definitions/request_steps.rb index cc7b10377b..bef59e18a4 100644 --- a/cucumber/api/features/step_definitions/request_steps.rb +++ b/cucumber/api/features/step_definitions/request_steps.rb @@ -192,6 +192,16 @@ expect(@result).to eq(role.credentials.api_key) end +Then(/^the role "([^"]*)" has (:?non-)?empty API key$/) do |role_name, full| + role = lookup_role(role_name) + role.reload + if full + expect(role.credentials.api_key).to be + else + expect(role.credentials.api_key).to be_nil + end +end + Then(/^it's confirmed$/) do expect(@http_status).to be_blank end diff --git a/cucumber/api/features/step_definitions/user_steps.rb b/cucumber/api/features/step_definitions/user_steps.rb index 254c621cce..ca47710da0 100644 --- a/cucumber/api/features/step_definitions/user_steps.rb +++ b/cucumber/api/features/step_definitions/user_steps.rb @@ -30,6 +30,16 @@ end end +When(/^I set annotation "([^"]*)" to "([^"]*)" on role "([^"]*)"$/) do |name, value, role| + Annotation[resource_id: role, name: name].tap do |a| + if a.nil? + Annotation.create(resource_id: role, name: name, value: value) + else + a.update(value: value) + end + end +end + Given("I create a new admin-owned user {string}") do |login| create_user login, admin_user end diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index bf2187b188..e2c83efe14 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -3,6 +3,7 @@ require('net/http') require('uri') +include Authentication::OptionalApiKey # Utility methods for making API requests # module RestHelpers @@ -265,7 +266,7 @@ def create_role roleid, owner, api_key_annotation=false if api_key_annotation role.annotations << Annotation.create(resource: resource, - name: "authn/api-key", + name: AUTHN_ANNOTATION, value: "true") end Credentials[role: role] || Credentials.new(role: role).save(raise_on_save_failure: true) diff --git a/db/migrate/20231005150946_optional_api_key_trigger.rb b/db/migrate/20231005150946_optional_api_key_trigger.rb new file mode 100644 index 0000000000..8031d974d8 --- /dev/null +++ b/db/migrate/20231005150946_optional_api_key_trigger.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +require_relative '../../app/models/schemata' + +Sequel.migration do + up do + execute Functions.create_authn_ann_trigger_sql(Schemata.new.primary_schema) + end + + down do + execute Functions.drop_authn_anno_trigger_sql + end +end \ No newline at end of file diff --git a/spec/app/domain/authentication/optional_api_key_spec.rb b/spec/app/domain/authentication/optional_api_key_spec.rb new file mode 100644 index 0000000000..151d13863f --- /dev/null +++ b/spec/app/domain/authentication/optional_api_key_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Authentication::OptionalApiKey do + + context "annotation check" do + + subject do + Class.new { include Authentication::OptionalApiKey }.new + end + + it { subject.annotation_true?(Annotation.new(name: 'authn/api-key', value: 'true')).should be_truthy } + + it { subject.annotation_true?(Annotation.new(name: 'authn/api-key', value: 'false')).should be_falsey } + + it { subject.annotation_true?(Annotation.new(name: 'authn/other-key', value: 'true')).should be_falsey } + end +end + diff --git a/spec/controllers/policies_controller_spec.rb b/spec/controllers/policies_controller_spec.rb index 5e8bd7f47d..7350a59b93 100644 --- a/spec/controllers/policies_controller_spec.rb +++ b/spec/controllers/policies_controller_spec.rb @@ -7,22 +7,10 @@ describe PoliciesController, type: :request do before(:all) do - # there doesn't seem to be a sane way to get this - @original_database_cleaner_strategy = - DatabaseCleaner.connections.first.strategy - .class.name.downcase[/[^:]+$/].intern - - # we need truncation here because the tests span many transactions - DatabaseCleaner.strategy = :truncation - # init Slosilo key init_slosilo_keys("rspec") end - after(:all) do - DatabaseCleaner.strategy = @original_database_cleaner_strategy - end - before do allow_any_instance_of(described_class).to( receive_messages(current_user: current_user) @@ -36,7 +24,7 @@ def variable(name) Resource["rspec:variable:#{name}"] end - describe '#post' do + context '#post' do before { put_payload('[!variable preexisting]') } let(:policies_url) do @@ -116,4 +104,30 @@ def put_payload(payload) vars.each { |var| expect(variable(var)).to exist } end end + + context "Created and modified roles" do + let(:created1) { create_host('rspec:host:created1', current_user).tap{|h| h.credentials[:api_key] = '123456'} } + let(:updated1) { create_host('rspec:host:updated1', current_user).tap{|h| h.credentials[:api_key] = 'APIKEY'} } + let(:updated2) { create_host('rspec:host:updated2', current_user).tap{|h| h.credentials[:api_key] = 'APIKEY'} } + + subject { described_class.new } + + it "update_roles modifies API key for associated credentials" do + allow(Credentials).to receive(:where).with(api_key: 'APIKEY') + .and_return([updated1.credentials, updated2. credentials]) + + roles = subject.send(:update_roles) + expect(roles.values.map{|r| r[:api_key]}).not_to include('APIKEY') + end + + it "created and updated roles are merged" do + policy_action = double('policy_action') + allow(policy_action).to receive(:call) + allow(policy_action).to receive(:new_roles).and_return([created1]) + allow(Credentials).to receive(:where).with(api_key: 'APIKEY') + .and_return([updated1.credentials, updated2. credentials]) + roles = subject.send(:perform, policy_action) + expect(roles.keys).to eq([created1.id, updated1.id, updated2.id]) + end + end end diff --git a/spec/models/credentials_spec.rb b/spec/models/credentials_spec.rb index 81ddc3a399..098c7611c2 100644 --- a/spec/models/credentials_spec.rb +++ b/spec/models/credentials_spec.rb @@ -153,6 +153,13 @@ end end + describe "role with APIKEY api key" do + it "replaces temp APIKEY with real value" do + credentials[:api_key] = 'APIKEY' + expect(credentials.api_key).not_to eq('APIKEY') + end + end + describe "with expiration" do let(:now) { Time.now } let(:past) { now - 1.second } diff --git a/spec/models/host_factory_spec.rb b/spec/models/host_factory_spec.rb index 953063950d..358d44729a 100644 --- a/spec/models/host_factory_spec.rb +++ b/spec/models/host_factory_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' +include Authentication::OptionalApiKey describe "HostFactory" do include_context "create user" @@ -96,12 +97,12 @@ end context 'when creating host with api-key annotation true' do - let(:options) { {annotations: {'authn/api-key' => true}} } + let(:options) { {annotations: { AUTHN_ANNOTATION => true}} } it { expect { host_builder.create_host }.to_not raise_error } end context 'when creating host with api-key annotation false' do - let(:options) { {annotations: {'authn/api-key' => false}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => false}} } it { expect { host_builder.create_host }.to_not raise_error } end @@ -116,17 +117,17 @@ end context 'when creating host with api-key annotation true' do - let(:options) { {annotations: {'authn/api-key' => true}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => true}} } it { expect { host_builder.create_host }.to_not raise_error } end context 'when creating host with api-key annotation false' do - let(:options) { {annotations: {'authn/api-key' => false}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => false}} } it { expect { host_builder.create_host }.to raise_error } end context 'when creating host with api-key annotation False capital' do - let(:options) { {annotations: {'authn/api-key' => "FALSE"}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => "FALSE"}} } it { expect { host_builder.create_host }.to raise_error } end @@ -142,22 +143,22 @@ end context 'when creating host with api-key annotation true' do - let(:options) { {annotations: {'authn/api-key' => 'true'}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => 'true'}} } it { expect(host_builder.create_host[1]).not_to be_nil } # create_host returns [host, api_key] end context 'when creating host with api-key annotation true' do - let(:options) { {annotations: {'authn/api-key' => 'TRUE'}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => 'TRUE'}} } it { expect(host_builder.create_host[1]).not_to be_nil } # create_host returns [host, api_key] end context 'when creating host with api-key annotation false' do - let(:options) { {annotations: {'authn/api-key' => false}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => false}} } it { expect(host_builder.create_host[1]).to be_nil } # create_host returns [host, api_key] end context 'when creating host with api-key annotation False capital' do - let(:options) { {annotations: {'authn/api-key' => "FALSE"}} } + let(:options) { {annotations: {AUTHN_ANNOTATION => "FALSE"}} } it { expect(host_builder.create_host[1]).to be_nil } # create_host returns [host, api_key] end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb index 05af3c4900..6767e75f4a 100644 --- a/spec/models/role_spec.rb +++ b/spec/models/role_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' +include Authentication::OptionalApiKey + describe Role, :type => :model do include_context "create user" @@ -46,17 +48,17 @@ subject(:role) { Role.create(role_id: "rspec:host:#{login}") } it "has API key when annotation is set to true" do - allow(subject).to receive(:annotations).and_return([Annotation.new(name: "authn/api-key", value: "true")]) + allow(subject).to receive(:annotations).and_return([Annotation.new(name: AUTHN_ANNOTATION, value: "true")]) expect(subject.api_key).to be_present end it "has API key when annotation is set to false" do - allow(subject).to receive(:annotations).and_return([Annotation.new(name: "authn/api-key", value: "false")]) + allow(subject).to receive(:annotations).and_return([Annotation.new(name: AUTHN_ANNOTATION, value: "false")]) expect(subject.api_key).to be_nil end it "has API key when annotation is set to blabla" do - allow(subject).to receive(:annotations).and_return([Annotation.new(name: "authn/api-key", value: "blabla")]) + allow(subject).to receive(:annotations).and_return([Annotation.new(name: AUTHN_ANNOTATION, value: "blabla")]) expect(subject.api_key).to be_nil end From edd7f1e513e226a6576d9452474d5c6c23148afe Mon Sep 17 00:00:00 2001 From: ltsirulnik Date: Sun, 15 Oct 2023 10:17:46 +0300 Subject: [PATCH 156/665] ONYX-45973: Add test for conjurctl retrieve api key --- cucumber/api/features/retrieve_api_key.feature | 9 +++++++++ .../api/features/step_definitions/conjurctl_steps.rb | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/cucumber/api/features/retrieve_api_key.feature b/cucumber/api/features/retrieve_api_key.feature index 44efd47a33..4a685d3f1e 100644 --- a/cucumber/api/features/retrieve_api_key.feature +++ b/cucumber/api/features/retrieve_api_key.feature @@ -15,3 +15,12 @@ Feature: Retrieving an API key with conjurctl Scenario: Retrieve an API key of a non-existing user fails When I retrieve an API key for user "cucumber:user:non-existing-user" using conjurctl Then the stderr includes the error "role does not exist" + + @smoke @skip + Scenario: Retrieve an API key for a host + Given I have host "api_key_host" + And I have host "without_api_key_host" without api key + When I retrieve an API key for user "cucumber:host:api_key_host" using conjurctl + Then the API key for "cucumber:host:api_key_host" is correct + When I retrieve an API key for user "cucumber:host:without_api_key_host" using conjurctl + Then the API key for "cucumber:host:without_api_key_host" is correct \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/conjurctl_steps.rb b/cucumber/api/features/step_definitions/conjurctl_steps.rb index 7b67e9f8a4..45c0acaa36 100644 --- a/cucumber/api/features/step_definitions/conjurctl_steps.rb +++ b/cucumber/api/features/step_definitions/conjurctl_steps.rb @@ -9,6 +9,10 @@ expect(@conjurctl_stdout).to eq("#{Credentials['cucumber:user:admin'].api_key}\n") end +Then(/^the API key for "([^"]*)" is correct$/) do |user_id| + expect(@conjurctl_stdout).to eq("#{Credentials[user_id].api_key}\n") +end + Then(/^the stderr includes the error "([^"]*)"$/) do |error| expect(@conjurctl_stderr).to include(error) end From 272bf59ccf48ca5a1ceb91f037a03c96566d741e Mon Sep 17 00:00:00 2001 From: Andy Tinkham Date: Fri, 21 Jul 2023 16:16:38 -0500 Subject: [PATCH 157/665] Remove httpclient private certs Signed-off-by: Andy Tinkham (cherry picked from commit 11b3aff02d734a09fa2f77fa003922910145bd71) --- Dockerfile.ubi | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile.ubi b/Dockerfile.ubi index 3c0e9614ba..53a90115af 100644 --- a/Dockerfile.ubi +++ b/Dockerfile.ubi @@ -73,13 +73,13 @@ RUN INSTALL_PKGS="gcc \ bundle --without test development && \ # Remove the build packages yum remove -y $INSTALL_PKGS && \ - yum -y clean all --enablerepo='*' + yum -y clean all --enablerepo='*' && \ + # removing CA bundle of httpclient gem + find / -name 'httpclient-*' -type d -exec find {} -name '*.pem' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; COPY . . -# removing CA bundle of httpclient gem -RUN find / -name httpclient -type d -exec find {} -name *.pem -type f -delete \; - RUN ln -sf /opt/conjur-server/bin/conjurctl /usr/local/bin/ COPY LICENSE.md /licenses/ From 3f65b39d3888f1eef7de12f25c710eae543c48fd Mon Sep 17 00:00:00 2001 From: Andy Tinkham Date: Thu, 27 Jul 2023 00:37:15 +0300 Subject: [PATCH 158/665] Upgrade rails and webrick to latest versions Signed-off-by: Andy Tinkham (cherry picked from commit bbd91b0dc127631ad7005649ad6ec071cb89b2bc) --- Gemfile.lock | 142 ++++++++++++++++++++++++++------------------------- 1 file changed, 72 insertions(+), 70 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 29b04ee9dd..55268ab039 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,60 +21,60 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + actioncable (6.1.7.4) + actionpack (= 6.1.7.4) + activesupport (= 6.1.7.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailbox (6.1.7.4) + actionpack (= 6.1.7.4) + activejob (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) mail (>= 2.7.1) - actionmailer (6.1.7.3) - actionpack (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailer (6.1.7.4) + actionpack (= 6.1.7.4) + actionview (= 6.1.7.4) + activejob (= 6.1.7.4) + activesupport (= 6.1.7.4) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.7.3) - actionview (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionpack (6.1.7.4) + actionview (= 6.1.7.4) + activesupport (= 6.1.7.4) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.3) - actionpack (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actiontext (6.1.7.4) + actionpack (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) nokogiri (>= 1.8.5) - actionview (6.1.7.3) - activesupport (= 6.1.7.3) + actionview (6.1.7.4) + activesupport (= 6.1.7.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.3) - activesupport (= 6.1.7.3) + activejob (6.1.7.4) + activesupport (= 6.1.7.4) globalid (>= 0.3.6) - activemodel (6.1.7.3) - activesupport (= 6.1.7.3) - activerecord (6.1.7.3) - activemodel (= 6.1.7.3) - activesupport (= 6.1.7.3) - activestorage (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activesupport (= 6.1.7.3) + activemodel (6.1.7.4) + activesupport (= 6.1.7.4) + activerecord (6.1.7.4) + activemodel (= 6.1.7.4) + activesupport (= 6.1.7.4) + activestorage (6.1.7.4) + actionpack (= 6.1.7.4) + activejob (= 6.1.7.4) + activerecord (= 6.1.7.4) + activesupport (= 6.1.7.4) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.3) + activesupport (6.1.7.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -277,9 +277,9 @@ GEM listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.20.0) + loofah (2.21.3) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.8.1) mini_mime (>= 0.1.1) net-imap @@ -291,10 +291,10 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2022.0105) mini_mime (1.1.2) - minitest (5.18.0) + minitest (5.19.0) multi_json (1.15.0) multi_test (0.1.2) - net-imap (0.3.4) + net-imap (0.3.7) date net-protocol net-ldap (0.17.0) @@ -307,9 +307,9 @@ GEM net-ssh (6.1.0) netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.14.3-x86_64-darwin) + nokogiri (1.15.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.14.3-x86_64-linux) + nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) openid_connect (1.3.0) activemodel @@ -340,8 +340,8 @@ GEM puma (5.6.4) nio4r (~> 2.0) raabro (1.4.0) - racc (1.6.2) - rack (2.2.6.4) + racc (1.7.1) + rack (2.2.7) rack-oauth2 (1.19.0) activesupport attr_required @@ -351,39 +351,41 @@ GEM rack-rewrite (1.5.1) rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.3) - actioncable (= 6.1.7.3) - actionmailbox (= 6.1.7.3) - actionmailer (= 6.1.7.3) - actionpack (= 6.1.7.3) - actiontext (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activemodel (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + rails (6.1.7.4) + actioncable (= 6.1.7.4) + actionmailbox (= 6.1.7.4) + actionmailer (= 6.1.7.4) + actionpack (= 6.1.7.4) + actiontext (= 6.1.7.4) + actionview (= 6.1.7.4) + activejob (= 6.1.7.4) + activemodel (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) bundler (>= 1.15.0) - railties (= 6.1.7.3) + railties (= 6.1.7.4) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.1.1) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) rails_12factor (0.0.3) rails_serve_static_assets rails_stdout_logging rails_layout (1.0.42) rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) - railties (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + railties (6.1.7.4) + actionpack (= 6.1.7.4) + activesupport (= 6.1.7.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -477,8 +479,8 @@ GEM sys-uname (1.2.2) ffi (~> 1.1) table_print (1.5.7) - thor (1.2.1) - timeout (0.3.2) + thor (1.2.2) + timeout (0.4.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unf (0.1.4) @@ -499,13 +501,13 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.7.0) + webrick (1.8.1) websocket (1.2.9) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xdg (2.2.3) - zeitwerk (2.6.7) + zeitwerk (2.6.9) PLATFORMS x86_64-darwin-20 From be473e36303b1405e99a96f1acd3b286e1e678ec Mon Sep 17 00:00:00 2001 From: codihuston <56605211+codihuston@users.noreply.github.com> Date: Thu, 27 Jul 2023 11:07:10 -0400 Subject: [PATCH 159/665] Add trivyignore for CONJSE-1795 (cherry picked from commit 980ded6554ff973f0c3a1921bc35816751fce18d) --- .trivyignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.trivyignore b/.trivyignore index afd9319e23..4dea0cceee 100644 --- a/.trivyignore +++ b/.trivyignore @@ -91,3 +91,7 @@ CVE-2021-3711 # is only available in premium support, trivy thinks we should use something in the 1.1.1 # line. We can't, due to FIPS compliance, so need to continue to ignore this issue. CVE-2023-0286 + +# Scanners pick up this vulnerability in OpenSSL::ASN1 module in Ruby before 2.2.8, 2.3.x before 2.3.5, and 2.4.x through 2.4.1 +# however we use ruby 3+ in production so we can safely ignore it. +CVE-2017-14033 From aa5358d3a508a6538e1406e3665e2f3b7703de52 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 2 Mar 2022 10:57:06 -0500 Subject: [PATCH 160/665] Add Prometheus scrape target (/metrics) and client store (cherry picked from commit 73d45ba581b9f730f2a7383f204c574662b133cc) --- Gemfile | 2 + Gemfile.lock | 2 + config/application.rb | 9 ++ config/initializers/rack_middleware.rb | 5 + .../middleware/prometheus_exporter.rb | 89 +++++++++++++++ lib/monitoring/prometheus.rb | 47 ++++++++ spec/monitoring/metrics_spec.rb | 15 +++ .../middleware/prometheus_exporter_spec.rb | 107 ++++++++++++++++++ 8 files changed, 276 insertions(+) create mode 100644 lib/monitoring/middleware/prometheus_exporter.rb create mode 100644 lib/monitoring/prometheus.rb create mode 100644 spec/monitoring/metrics_spec.rb create mode 100644 spec/monitoring/middleware/prometheus_exporter_spec.rb diff --git a/Gemfile b/Gemfile index 0301f54f33..e3a33d72c4 100644 --- a/Gemfile +++ b/Gemfile @@ -79,6 +79,8 @@ gem 'openid_connect' gem "anyway_config" gem 'i18n', '~> 1.8.11' +gem 'prometheus-client' + group :development, :test do gem 'aruba' gem 'ci_reporter_rspec' diff --git a/Gemfile.lock b/Gemfile.lock index 55268ab039..08eed7f34e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -328,6 +328,7 @@ GEM ast (~> 2.4.1) pg (1.2.3) powerpack (0.1.3) + prometheus-client (3.0.0) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) @@ -558,6 +559,7 @@ DEPENDENCIES parallel parallel_tests pg + prometheus-client pry-byebug pry-rails puma (~> 5.6) diff --git a/config/application.rb b/config/application.rb index 0d05d3dd47..2612f75f7c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,6 +24,12 @@ # Must require because lib folder hasn't been loaded yet require './lib/conjur/conjur_config' +# Require prometheus dependencies and metrics module +# so that a clean data store can be initialized +# This should be done dynamically depending on whether +# metrics are enabled in the future +require './lib/monitoring/prometheus' + module Conjur class Application < Rails::Application # Settings in config/environments/* take precedence over those specified here. @@ -73,5 +79,8 @@ class Application < Rails::Application config.anyway_config.future.unwrap_known_environments = true config.anyway_config.default_config_path = "/etc/conjur/config" + + # Initialize metrics and clean existing data + Monitoring::Prometheus.setup end end diff --git a/config/initializers/rack_middleware.rb b/config/initializers/rack_middleware.rb index fa8b79e8c5..0cda06d165 100644 --- a/config/initializers/rack_middleware.rb +++ b/config/initializers/rack_middleware.rb @@ -29,6 +29,11 @@ # to the start of the Rack middleware chain. config.middleware.insert_before(0, ::Rack::DefaultContentType) + # If using Prometheus telemetry, we want to ensure that the middleware + # which collects and exports metrics is loaded at the start of the + # middleware chain to prevent any modifications to the incoming requests + config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: Monitoring::Prometheus.registry, path: '/metrics') + # Deleting the RemoteIp middleware means that `request.remote_ip` will # always be the same as `request.ip`. This ensure that the Conjur request log # (using `remote_ip`) and the audit log (using `ip`) will have the same value diff --git a/lib/monitoring/middleware/prometheus_exporter.rb b/lib/monitoring/middleware/prometheus_exporter.rb new file mode 100644 index 0000000000..83402c814b --- /dev/null +++ b/lib/monitoring/middleware/prometheus_exporter.rb @@ -0,0 +1,89 @@ +require 'prometheus/client' +require 'prometheus/client/formats/text' + +module Monitoring + module Middleware + # Exporter is a Rack middleware that provides a sample implementation of a + # Prometheus HTTP exposition endpoint. + # + # By default it will export the state of the global registry and expose it + # under `/metrics`. Use the `:registry` and `:path` options to change the + # defaults. + class PrometheusExporter + attr_reader :app, :path, :registry + + FORMATS = [::Prometheus::Client::Formats::Text].freeze + FALLBACK = ::Prometheus::Client::Formats::Text + + def initialize(app, options = {}) + @app = app + @path = options[:path] + @registry = options[:registry] + @acceptable = build_dictionary(FORMATS, FALLBACK) + end + + def call(env) + if env['PATH_INFO'] == @path + format = negotiate(env, @acceptable) + format ? respond_with(format) : not_acceptable(FORMATS) + else + @app.call(env) + end + end + + private + + def negotiate(env, formats) + parse(env.fetch('HTTP_ACCEPT', '*/*')).each do |content_type, _| + return formats[content_type] if formats.key?(content_type) + end + + nil + end + + def parse(header) + header.split(/\s*,\s*/).map do |type| + attributes = type.split(/\s*;\s*/) + quality = extract_quality(attributes) + + [attributes.join('; '), quality] + end.sort_by(&:last).reverse + end + + def extract_quality(attributes, default = 1.0) + quality = default + + attributes.delete_if do |attr| + quality = attr.split('q=').last.to_f if attr.start_with?('q=') + end + + quality + end + + def respond_with(format) + [ + 200, + { 'Content-Type' => format::CONTENT_TYPE }, + [format.marshal(@registry)] + ] + end + + def not_acceptable(formats) + types = formats.map { |format| format::MEDIA_TYPE } + + [ + 406, + { 'Content-Type' => 'text/plain' }, + ["Supported media types: #{types.join(', ')}"] + ] + end + + def build_dictionary(formats, fallback) + formats.each_with_object('*/*' => fallback) do |format, memo| + memo[format::CONTENT_TYPE] = format + memo[format::MEDIA_TYPE] = format + end + end + end + end +end diff --git a/lib/monitoring/prometheus.rb b/lib/monitoring/prometheus.rb new file mode 100644 index 0000000000..7ff1468a9c --- /dev/null +++ b/lib/monitoring/prometheus.rb @@ -0,0 +1,47 @@ +require 'prometheus/client' +require 'prometheus/client/data_stores/direct_file_store' + +module Monitoring + module Prometheus + extend self + + def setup(options = {}) + @registry = options[:registry] || ::Prometheus::Client::Registry.new + @metrics_prefix = options[:metrics_prefix] || "conjur_http_server" + @metrics_dir_path = ENV['CONJUR_METRICS_DIR'] || '/tmp/prometheus' + + clear_data_store + configure_data_store + init_metrics + end + + def registry + @registry + end + + def metrics_prefix + @metrics_prefix + end + + protected + + def clear_data_store + Dir[File.join(@metrics_dir_path, '*.bin')].each do |file_path| + File.unlink(file_path) + end + end + + def configure_data_store + ::Prometheus::Client.config.data_store = ::Prometheus::Client::DataStores::DirectFileStore.new( + dir: @metrics_dir_path + ) + end + + def init_metrics + # Test a random gauge metric + gauge = registry.gauge(:test_gauge, docstring: '...', labels: [:test_label]) + gauge.set(1234.567, labels: { test_label: 'gauge metric test' }) + end + + end +end diff --git a/spec/monitoring/metrics_spec.rb b/spec/monitoring/metrics_spec.rb new file mode 100644 index 0000000000..a88b9ab14e --- /dev/null +++ b/spec/monitoring/metrics_spec.rb @@ -0,0 +1,15 @@ +require 'rack/test' +require 'prometheus/client/formats/text' +require 'monitoring/prometheus' + +describe Monitoring::Prometheus do + include Rack::Test::Methods + + it 'creates a valid registry and allows metrics' do + Monitoring::Prometheus.setup(registry: Prometheus::Client::Registry.new) + gauge = Monitoring::Prometheus.registry.gauge(:foo, docstring: '...', labels: [:bar]) + gauge.set(21.534, labels: { bar: 'test' }) + + expect(gauge.get(labels: { bar: 'test' })).to eql(21.534) + end +end diff --git a/spec/monitoring/middleware/prometheus_exporter_spec.rb b/spec/monitoring/middleware/prometheus_exporter_spec.rb new file mode 100644 index 0000000000..4976c98588 --- /dev/null +++ b/spec/monitoring/middleware/prometheus_exporter_spec.rb @@ -0,0 +1,107 @@ +require 'rack/test' +require 'monitoring/middleware/prometheus_exporter' + +describe Monitoring::Middleware::PrometheusExporter do + include Rack::Test::Methods + + # Reset the data store + before do + Monitoring::Prometheus.setup(registry: Prometheus::Client::Registry.new) + end + + let(:registry) do + Monitoring::Prometheus.registry + end + + let(:path) { '/metrics' } + + let(:options) { { registry: registry, path: path} } + + let(:app) do + app = ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } + described_class.new(app, **options) + end + + context 'when requesting app endpoints' do + it 'returns the app response' do + get '/foo' + + expect(last_response).to be_ok + expect(last_response.body).to eql('OK') + end + end + + context 'when requesting /metrics' do + text = Prometheus::Client::Formats::Text + + shared_examples 'ok' do |headers, fmt| + it "responds with 200 OK and Content-Type #{fmt::CONTENT_TYPE}" do + registry.counter(:foo, docstring: 'foo counter').increment(by: 9) + + get '/metrics', nil, headers + + expect(last_response.status).to eql(200) + expect(last_response.header['Content-Type']).to eql(fmt::CONTENT_TYPE) + expect(last_response.body).to eql(fmt.marshal(registry)) + end + end + + shared_examples 'not acceptable' do |headers| + it 'responds with 406 Not Acceptable' do + message = 'Supported media types: text/plain' + + get '/metrics', nil, headers + + expect(last_response.status).to eql(406) + expect(last_response.header['Content-Type']).to eql('text/plain') + expect(last_response.body).to eql(message) + end + end + + context 'when client does not send a Accept header' do + include_examples 'ok', {}, text + end + + context 'when client accepts any media type' do + include_examples 'ok', { 'HTTP_ACCEPT' => '*/*' }, text + end + + context 'when client requests application/json' do + include_examples 'not acceptable', 'HTTP_ACCEPT' => 'application/json' + end + + context 'when client requests text/plain' do + include_examples 'ok', { 'HTTP_ACCEPT' => 'text/plain' }, text + end + + context 'when client uses different white spaces in Accept header' do + accept = 'text/plain;q=1.0 ; version=0.0.4' + + include_examples 'ok', { 'HTTP_ACCEPT' => accept }, text + end + + context 'when client does not include quality attribute' do + accept = 'application/json;q=0.5, text/plain' + + include_examples 'ok', { 'HTTP_ACCEPT' => accept }, text + end + + context 'when client accepts some unknown formats' do + accept = 'text/plain;q=0.3, proto/buf;q=0.7' + + include_examples 'ok', { 'HTTP_ACCEPT' => accept }, text + end + + context 'when client accepts only unknown formats' do + accept = 'fancy/woo;q=0.3, proto/buf;q=0.7' + + include_examples 'not acceptable', 'HTTP_ACCEPT' => accept + end + + context 'when client accepts unknown formats and wildcard' do + accept = 'fancy/woo;q=0.3, proto/buf;q=0.7, */*;q=0.1' + + include_examples 'ok', { 'HTTP_ACCEPT' => accept }, text + end + end +end From ede9070334c482166e509cbd663bb9da3538eb2d Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Tue, 8 Mar 2022 14:17:58 -0500 Subject: [PATCH 161/665] Add injectable lib for Pub/Sub mechanics Pub/Sub lib is injected into the Prometheus controller as the basis for metric updates and their triggers. (cherry picked from commit 95425c64085c53c1e20e88fbd7508f046b255dd7) --- lib/monitoring/prometheus.rb | 17 +++-- lib/monitoring/pub_sub.rb | 24 +++++++ spec/monitoring/metrics_spec.rb | 68 ++++++++++++++++++- .../middleware/prometheus_exporter_spec.rb | 1 + spec/monitoring/pub_sub_spec.rb | 47 +++++++++++++ 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 lib/monitoring/pub_sub.rb create mode 100644 spec/monitoring/pub_sub_spec.rb diff --git a/lib/monitoring/prometheus.rb b/lib/monitoring/prometheus.rb index 7ff1468a9c..a6cd46ba2a 100644 --- a/lib/monitoring/prometheus.rb +++ b/lib/monitoring/prometheus.rb @@ -1,5 +1,6 @@ require 'prometheus/client' require 'prometheus/client/data_stores/direct_file_store' +require 'monitoring/pub_sub' module Monitoring module Prometheus @@ -9,10 +10,16 @@ def setup(options = {}) @registry = options[:registry] || ::Prometheus::Client::Registry.new @metrics_prefix = options[:metrics_prefix] || "conjur_http_server" @metrics_dir_path = ENV['CONJUR_METRICS_DIR'] || '/tmp/prometheus' + @pubsub = options[:pubsub] || PubSub.instance + + # Array of objects representing different metrics. + # Each objects needs a .setup method, responsible for registering metrics + # and subscribing to Pub/Sub events. + @metrics = options[:metrics] || [] clear_data_store configure_data_store - init_metrics + setup_metrics end def registry @@ -37,10 +44,10 @@ def configure_data_store ) end - def init_metrics - # Test a random gauge metric - gauge = registry.gauge(:test_gauge, docstring: '...', labels: [:test_label]) - gauge.set(1234.567, labels: { test_label: 'gauge metric test' }) + def setup_metrics + @metrics.each do |metric| + metric.setup(@registry, @pubsub) + end end end diff --git a/lib/monitoring/pub_sub.rb b/lib/monitoring/pub_sub.rb new file mode 100644 index 0000000000..66c7eeb1e0 --- /dev/null +++ b/lib/monitoring/pub_sub.rb @@ -0,0 +1,24 @@ +require 'singleton' +require 'active_support/notifications' + +module Monitoring + # PubSub wraps ActiveSupport::Notifications, providing pub/sub + # plumbing to custom controllers and collectors. + class PubSub + include Singleton + + def publish(name, payload = {}) + ActiveSupport::Notifications.instrument(name, payload) + end + + def subscribe(name) + ActiveSupport::Notifications.subscribe(name) do |_, _, _, _, payload| + yield payload + end + end + + def unsubscribe(name) + ActiveSupport::Notifications.unsubscribe(name) + end + end +end diff --git a/spec/monitoring/metrics_spec.rb b/spec/monitoring/metrics_spec.rb index a88b9ab14e..7d56fbd4de 100644 --- a/spec/monitoring/metrics_spec.rb +++ b/spec/monitoring/metrics_spec.rb @@ -1,15 +1,79 @@ require 'rack/test' require 'prometheus/client/formats/text' require 'monitoring/prometheus' +require 'monitoring/pub_sub' + +class SampleMetric + def setup(registry, pubsub) + registry.register(::Prometheus::Client::Gauge.new( + :test_gauge, + docstring: '...', + labels: [:test_label] + )) + + pubsub.subscribe("sample_test_gauge") do |payload| + metric = registry.get(:test_gauge) + metric.set(payload[:value], labels: payload[:labels]) + end + end +end describe Monitoring::Prometheus do include Rack::Test::Methods + let(:registry) { + Monitoring::Prometheus.setup + Monitoring::Prometheus.registry + } + it 'creates a valid registry and allows metrics' do - Monitoring::Prometheus.setup(registry: Prometheus::Client::Registry.new) - gauge = Monitoring::Prometheus.registry.gauge(:foo, docstring: '...', labels: [:bar]) + gauge = registry.gauge(:foo, docstring: '...', labels: [:bar]) gauge.set(21.534, labels: { bar: 'test' }) expect(gauge.get(labels: { bar: 'test' })).to eql(21.534) end + + it 'can use Pub/Sub events to update metrics on the registry' do + gauge = registry.gauge(:foo, docstring: '...', labels: [:bar]) + + pub_sub = Monitoring::PubSub.instance + pub_sub.subscribe("foo_event_name") do |payload| + labels = { + bar: payload[:bar] + } + gauge.set(payload[:value], labels: labels) + end + + pub_sub.publish("foo_event_name", value: 100, bar: "omicron") + expect(gauge.get(labels: { bar: "omicron" })).to eql(100.0) + end + + context 'when given a list of metrics to setup' do + before do + @metric_obj = SampleMetric.new + @registry = ::Prometheus::Client::Registry.new + @mock_pubsub = double("Mock Monitoring::PubSub") + end + + def prometheus_setup + Monitoring::Prometheus.setup( + registry: @registry, + metrics: [ @metric_obj ], + pubsub: @mock_pubsub + ) + end + + it 'calls .setup for the metric class' do + expect(@metric_obj).to receive(:setup).with(@registry, @mock_pubsub) + prometheus_setup + end + + it 'adds custom metric definitions to the global registry and subscribes to related Pub/Sub events' do + expect(@mock_pubsub).to receive(:subscribe).with("sample_test_gauge") + prometheus_setup + + sample_metric = @registry.get(:test_gauge) + expect(sample_metric).not_to be_nil + end + end end diff --git a/spec/monitoring/middleware/prometheus_exporter_spec.rb b/spec/monitoring/middleware/prometheus_exporter_spec.rb index 4976c98588..9104d29bc5 100644 --- a/spec/monitoring/middleware/prometheus_exporter_spec.rb +++ b/spec/monitoring/middleware/prometheus_exporter_spec.rb @@ -1,5 +1,6 @@ require 'rack/test' require 'monitoring/middleware/prometheus_exporter' +require 'monitoring/prometheus' describe Monitoring::Middleware::PrometheusExporter do include Rack::Test::Methods diff --git a/spec/monitoring/pub_sub_spec.rb b/spec/monitoring/pub_sub_spec.rb new file mode 100644 index 0000000000..4c15ef3d23 --- /dev/null +++ b/spec/monitoring/pub_sub_spec.rb @@ -0,0 +1,47 @@ +require 'rack/test' +require 'spec_helper' +require 'monitoring/pub_sub' + +describe Monitoring::PubSub do + include Rack::Test::Methods + + let(:pubsub) { Monitoring::PubSub.instance } + + it 'unsubscribes blocks from a named event' do + expect { |block| + # Assert that each #subscribe call produces a + # unique subscriber to event "A". + a_sub_1 = pubsub.subscribe("A", &block) + a_sub_2 = pubsub.subscribe("A", &block) + expect(a_sub_1).not_to equal(a_sub_2) + + pubsub.subscribe("B", &block) + + # Arg {e:1} will be yielded twice, once by each + # unique subscriber to event "A". + pubsub.publish("A", {e:1}) + pubsub.publish("B", {e:2}) + + pubsub.unsubscribe("A") + pubsub.publish("A", {e:3}) + pubsub.publish("B", {e:4}) + } + .to yield_successive_args({e:1}, {e:1}, {e:2}, {e:4}) + end + + it 'receives only subscribed events, in order published' do + expect { |block| + names = [ "A", "B", "C" ] + names.each { |name| + pubsub.subscribe(name, &block) + } + + pubsub.publish("B", {e:1}) + pubsub.publish("C", {e:2}) + pubsub.publish("D", {e:3}) + pubsub.publish("A", {e:4}) + } + .to yield_successive_args({e:1}, {e:2}, {e:4}) + end + +end From 9d59169cad068e2e6996474b96e5c5783baab548 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Tue, 22 Mar 2022 13:26:39 -0400 Subject: [PATCH 162/665] Reorganize RSpec tests for Monitoring library and update requirements (cherry picked from commit 9fb4e498c4a42491c6d4a967fa60dd0ea2e5aa69) --- lib/monitoring/prometheus.rb | 2 +- spec/{ => lib}/monitoring/metrics_spec.rb | 3 +-- .../monitoring/middleware/prometheus_exporter_spec.rb | 3 +-- spec/{ => lib}/monitoring/pub_sub_spec.rb | 1 - 4 files changed, 3 insertions(+), 6 deletions(-) rename spec/{ => lib}/monitoring/metrics_spec.rb (97%) rename spec/{ => lib}/monitoring/middleware/prometheus_exporter_spec.rb (97%) rename spec/{ => lib}/monitoring/pub_sub_spec.rb (97%) diff --git a/lib/monitoring/prometheus.rb b/lib/monitoring/prometheus.rb index a6cd46ba2a..d6c8cfb9dd 100644 --- a/lib/monitoring/prometheus.rb +++ b/lib/monitoring/prometheus.rb @@ -1,6 +1,6 @@ require 'prometheus/client' require 'prometheus/client/data_stores/direct_file_store' -require 'monitoring/pub_sub' +require_relative './pub_sub' module Monitoring module Prometheus diff --git a/spec/monitoring/metrics_spec.rb b/spec/lib/monitoring/metrics_spec.rb similarity index 97% rename from spec/monitoring/metrics_spec.rb rename to spec/lib/monitoring/metrics_spec.rb index 7d56fbd4de..92677a6195 100644 --- a/spec/monitoring/metrics_spec.rb +++ b/spec/lib/monitoring/metrics_spec.rb @@ -1,7 +1,6 @@ require 'rack/test' require 'prometheus/client/formats/text' -require 'monitoring/prometheus' -require 'monitoring/pub_sub' +require 'spec_helper' class SampleMetric def setup(registry, pubsub) diff --git a/spec/monitoring/middleware/prometheus_exporter_spec.rb b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb similarity index 97% rename from spec/monitoring/middleware/prometheus_exporter_spec.rb rename to spec/lib/monitoring/middleware/prometheus_exporter_spec.rb index 9104d29bc5..ca8d2bf1ad 100644 --- a/spec/monitoring/middleware/prometheus_exporter_spec.rb +++ b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb @@ -1,6 +1,5 @@ require 'rack/test' -require 'monitoring/middleware/prometheus_exporter' -require 'monitoring/prometheus' +require 'spec_helper' describe Monitoring::Middleware::PrometheusExporter do include Rack::Test::Methods diff --git a/spec/monitoring/pub_sub_spec.rb b/spec/lib/monitoring/pub_sub_spec.rb similarity index 97% rename from spec/monitoring/pub_sub_spec.rb rename to spec/lib/monitoring/pub_sub_spec.rb index 4c15ef3d23..b5494dba04 100644 --- a/spec/monitoring/pub_sub_spec.rb +++ b/spec/lib/monitoring/pub_sub_spec.rb @@ -1,6 +1,5 @@ require 'rack/test' require 'spec_helper' -require 'monitoring/pub_sub' describe Monitoring::PubSub do include Rack::Test::Methods From 88608b04d2780bebc3f33d6aac7fc06a804045e9 Mon Sep 17 00:00:00 2001 From: Kumbirai Tanekha Date: Wed, 23 Mar 2022 10:45:36 +0000 Subject: [PATCH 163/665] Clean up spec/lib/monitoring imports Remove the 'include Rack::Test::Methods' where it is not needed. Remove importing 'rack/test' since rack seems to be already loaded (cherry picked from commit 840415d519398db9452fa6539715ef90509d6a41) --- spec/lib/monitoring/metrics_spec.rb | 5 +---- spec/lib/monitoring/middleware/prometheus_exporter_spec.rb | 1 - spec/lib/monitoring/pub_sub_spec.rb | 3 --- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/spec/lib/monitoring/metrics_spec.rb b/spec/lib/monitoring/metrics_spec.rb index 92677a6195..bb10d78070 100644 --- a/spec/lib/monitoring/metrics_spec.rb +++ b/spec/lib/monitoring/metrics_spec.rb @@ -1,6 +1,5 @@ -require 'rack/test' -require 'prometheus/client/formats/text' require 'spec_helper' +require 'prometheus/client/formats/text' class SampleMetric def setup(registry, pubsub) @@ -18,8 +17,6 @@ def setup(registry, pubsub) end describe Monitoring::Prometheus do - include Rack::Test::Methods - let(:registry) { Monitoring::Prometheus.setup Monitoring::Prometheus.registry diff --git a/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb index ca8d2bf1ad..6fd7809795 100644 --- a/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb +++ b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb @@ -1,4 +1,3 @@ -require 'rack/test' require 'spec_helper' describe Monitoring::Middleware::PrometheusExporter do diff --git a/spec/lib/monitoring/pub_sub_spec.rb b/spec/lib/monitoring/pub_sub_spec.rb index b5494dba04..126a2449c8 100644 --- a/spec/lib/monitoring/pub_sub_spec.rb +++ b/spec/lib/monitoring/pub_sub_spec.rb @@ -1,9 +1,6 @@ -require 'rack/test' require 'spec_helper' describe Monitoring::PubSub do - include Rack::Test::Methods - let(:pubsub) { Monitoring::PubSub.instance } it 'unsubscribes blocks from a named event' do From 9c4776e4c0406c8a43f70870641087db50db32ed Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Tue, 12 Apr 2022 16:43:15 +0300 Subject: [PATCH 164/665] Load telemetry_enabled value in ConjurConfig, add Prometheus initializer (cherry picked from commit 7ced60c3255bcd1e465c6e241ed7d4b767a8af4c) --- config/application.rb | 9 --------- config/initializers/prometheus.rb | 7 +++++++ config/initializers/rack_middleware.rb | 5 ----- dev/start | 12 ++++++++++++ lib/conjur/conjur_config.rb | 6 ++++++ spec/lib/conjur/conjur_config_spec.rb | 26 +++++++++++++++++++++++++- spec/lib/monitoring/metrics_spec.rb | 2 +- spec/lib/monitoring/pub_sub_spec.rb | 2 +- 8 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 config/initializers/prometheus.rb diff --git a/config/application.rb b/config/application.rb index 2612f75f7c..0d05d3dd47 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,12 +24,6 @@ # Must require because lib folder hasn't been loaded yet require './lib/conjur/conjur_config' -# Require prometheus dependencies and metrics module -# so that a clean data store can be initialized -# This should be done dynamically depending on whether -# metrics are enabled in the future -require './lib/monitoring/prometheus' - module Conjur class Application < Rails::Application # Settings in config/environments/* take precedence over those specified here. @@ -79,8 +73,5 @@ class Application < Rails::Application config.anyway_config.future.unwrap_known_environments = true config.anyway_config.default_config_path = "/etc/conjur/config" - - # Initialize metrics and clean existing data - Monitoring::Prometheus.setup end end diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb new file mode 100644 index 0000000000..ad711aa2c7 --- /dev/null +++ b/config/initializers/prometheus.rb @@ -0,0 +1,7 @@ + # If using Prometheus telemetry, we want to ensure that the middleware + # which collects and exports metrics is loaded at the start of the + # middleware chain to prevent any modifications to the incoming requests + if Rails.application.config.conjur_config.telemetry_enabled + Monitoring::Prometheus.setup + Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: Monitoring::Prometheus.registry, path: '/metrics') + end \ No newline at end of file diff --git a/config/initializers/rack_middleware.rb b/config/initializers/rack_middleware.rb index 0cda06d165..fa8b79e8c5 100644 --- a/config/initializers/rack_middleware.rb +++ b/config/initializers/rack_middleware.rb @@ -29,11 +29,6 @@ # to the start of the Rack middleware chain. config.middleware.insert_before(0, ::Rack::DefaultContentType) - # If using Prometheus telemetry, we want to ensure that the middleware - # which collects and exports metrics is loaded at the start of the - # middleware chain to prevent any modifications to the incoming requests - config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: Monitoring::Prometheus.registry, path: '/metrics') - # Deleting the RemoteIp middleware means that `request.remote_ip` will # always be the same as `request.ip`. This ensure that the Conjur request log # (using `remote_ip`) and the audit log (using `ip`) will have the same value diff --git a/dev/start b/dev/start index 3960660df5..5bc50c8932 100755 --- a/dev/start +++ b/dev/start @@ -30,6 +30,7 @@ ENABLE_AUTHN_IAM=false ENABLE_AUTHN_JWT=false ENABLE_AUTHN_LDAP=false ENABLE_AUTHN_OIDC=false +ENABLE_METRICS=false ENABLE_OIDC_ADFS=false ENABLE_OIDC_IDENTITY=false ENABLE_OIDC_KEYCLOAK=false @@ -77,6 +78,8 @@ main() { init_oidc init_rotators init_ephemeral_secrets + init_metrics + # Updates CONJUR_AUTHENTICATORS and restarts required services. start_auth_services create_alice @@ -101,6 +104,7 @@ Usage: start [options] 'curl -X POST -d "alice" http://localhost:3000/authn-ldap/test/cucumber/alice/authenticate' -h, --help Shows this help message. --identity-user Identity user to create in Conjur + --metrics Starts with the prometheus telemetry features enabled --oidc-adfs Adds to authn-oidc adfs static env configuration --oidc-identity Starts with authn-oidc/identity as available authenticator. Must be paired with --identity-user flag. @@ -127,6 +131,7 @@ parse_options() { --authn-iam ) ENABLE_AUTHN_IAM=true ; shift ;; --authn-jwt ) ENABLE_AUTHN_JWT=true ; ENABLE_OIDC_KEYCLOAK=true ; shift ;; --authn-ldap ) ENABLE_AUTHN_LDAP=true ; shift ;; + --metrics ) ENABLE_METRICS=true ; shift ;; -h | --help ) print_help ; shift ;; --identity-user ) IDENTITY_USER="$2" ; shift ; shift ;; --oidc-adfs ) ENABLE_AUTHN_OIDC=true ; ENABLE_OIDC_ADFS=true ; shift ;; @@ -498,6 +503,13 @@ init_iam() { "/src/conjur-server/dev/files/authn-iam/policy.yml" } +init_metrics() { + if [[ $ENABLE_METRICS != true ]]; then + return + fi + env_args+=(-e "CONJUR_TELEMETRY_ENABLED=true") +} + start_auth_services() { echo "Setting CONJUR_AUTHENTICATORS to: $enabled_authenticators" env_args+=(-e "CONJUR_AUTHENTICATORS=$enabled_authenticators") diff --git a/lib/conjur/conjur_config.rb b/lib/conjur/conjur_config.rb index 40f0f0c382..fb776a8bac 100644 --- a/lib/conjur/conjur_config.rb +++ b/lib/conjur/conjur_config.rb @@ -39,6 +39,7 @@ class ConjurConfig < Anyway::Config authn_api_key_default: true, authenticators: [], extensions: [], + telemetry_enabled: false, slosilo_rotation_interval: 24, # Sloislo rotation should be every 24 hours tenant_id: @tenant_id, tenant_name: @tenant_name, @@ -93,6 +94,7 @@ def initialize( invalid << "trusted_proxies" unless trusted_proxies_valid? invalid << "authenticators" unless authenticators_valid? + invalid << "telemetry_enabled" unless telemetry_enabled_valid? unless invalid.empty? msg = "Invalid values for configured attributes: #{invalid.join(',')}" @@ -270,5 +272,9 @@ def authenticators_valid? rescue false end + + def telemetry_enabled_valid? + [true, false].include? telemetry_enabled + end end end diff --git a/spec/lib/conjur/conjur_config_spec.rb b/spec/lib/conjur/conjur_config_spec.rb index 88b05c6431..d4c07fb59f 100644 --- a/spec/lib/conjur/conjur_config_spec.rb +++ b/spec/lib/conjur/conjur_config_spec.rb @@ -20,11 +20,14 @@ it "uses default value if not set by environment variable or config file" do expect(subject.trusted_proxies).to eq([]) + expect(subject.telemetry_enabled).to eq(false) end it "reports the attribute source as :defaults" do expect(subject.attribute_sources[:trusted_proxies]). to eq(:defaults) + expect(subject.attribute_sources[:telemetry_enabled]). + to eq(:defaults) end context "with config file" do @@ -32,6 +35,7 @@ <<~YAML trusted_proxies: - 1.2.3.4 + telemetry_enabled: true YAML end @@ -58,11 +62,14 @@ it "reads config value from file" do expect(subject.trusted_proxies).to eq(["1.2.3.4"]) + expect(subject.telemetry_enabled).to eq(true) end it "reports the attribute source as :yml" do expect(subject.attribute_sources[:trusted_proxies]). to eq(:yml) + expect(subject.attribute_sources[:telemetry_enabled]). + to eq(:yml) end context "with a config file that is a string value" do @@ -121,6 +128,7 @@ context "with prefixed env var" do before do ENV['CONJUR_TRUSTED_PROXIES'] = "5.6.7.8" + ENV['CONJUR_TELEMETRY_ENABLED'] = "false" # Anyway Config caches prefixed env vars at the class level so we must # clear the cache to have it pick up the new var with a reload. @@ -129,6 +137,7 @@ after do ENV.delete('CONJUR_TRUSTED_PROXIES') + ENV.delete('CONJUR_TELEMETRY_ENABLED') # Clear again to make sure we don't affect future tests. Anyway.env.clear @@ -136,11 +145,14 @@ it "overrides the config file value" do expect(subject.trusted_proxies).to eq(["5.6.7.8"]) + expect(subject.telemetry_enabled).to eq(false) end it "reports the attribute source as :env" do expect(subject.attribute_sources[:trusted_proxies]). to eq(:env) + expect(subject.attribute_sources[:telemetry_enabled]). + to eq(:env) end end @@ -171,7 +183,8 @@ let(:config_kwargs) do { authenticators: "invalid-authn", - trusted_proxies: "boop" + trusted_proxies: "boop", + telemetry_enabled: "beep" } end @@ -185,6 +198,8 @@ to raise_error(/trusted_proxies/) expect { subject }. to raise_error(/authenticators/) + expect { subject }. + to raise_error(/telemetry_enabled/) end it "does not include the value that failed validation" do @@ -192,6 +207,8 @@ to_not raise_error(/boop/) expect { subject }. to_not raise_error(/invalid-authn/) + expect { subject }. + to_not raise_error(/beep/) end end @@ -347,6 +364,13 @@ end end end + + describe "metrics endpoint is disabled by default", type: :request do + it "returns a 401" do + get '/metrics' + expect(response).to have_http_status(401) + end + end end # Helper method for the config file tests to create a temporary directory for diff --git a/spec/lib/monitoring/metrics_spec.rb b/spec/lib/monitoring/metrics_spec.rb index bb10d78070..26525ba571 100644 --- a/spec/lib/monitoring/metrics_spec.rb +++ b/spec/lib/monitoring/metrics_spec.rb @@ -1,5 +1,5 @@ -require 'spec_helper' require 'prometheus/client/formats/text' +require 'monitoring/prometheus' class SampleMetric def setup(registry, pubsub) diff --git a/spec/lib/monitoring/pub_sub_spec.rb b/spec/lib/monitoring/pub_sub_spec.rb index 126a2449c8..2dd8fa7a9b 100644 --- a/spec/lib/monitoring/pub_sub_spec.rb +++ b/spec/lib/monitoring/pub_sub_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'monitoring/pub_sub' describe Monitoring::PubSub do let(:pubsub) { Monitoring::PubSub.instance } From cf4a1f4f2b6f0e1bf7c6d6159d328f54c7231af3 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Tue, 12 Apr 2022 09:43:46 -0400 Subject: [PATCH 165/665] Cleanup exporter tests (cherry picked from commit 3b36e492502e8d0b558038b1847f54945859c4f5) --- .../middleware/prometheus_exporter_spec.rb | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb index 6fd7809795..22a8c5cbca 100644 --- a/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb +++ b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' +require 'monitoring/middleware/prometheus_exporter' describe Monitoring::Middleware::PrometheusExporter do - include Rack::Test::Methods # Reset the data store before do @@ -16,17 +16,21 @@ let(:options) { { registry: registry, path: path} } + let(:env) { Rack::MockRequest.env_for } + let(:app) do - app = ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } - described_class.new(app, **options) + app = ->(env) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } end + subject { described_class.new(app, **options) } + context 'when requesting app endpoints' do it 'returns the app response' do - get '/foo' + env['PATH_INFO'] = "/foo" + status, _headers, _response = subject.call(env) - expect(last_response).to be_ok - expect(last_response.body).to eql('OK') + expect(status).to eql(200) + expect(_response.first).to eql('OK') end end @@ -36,12 +40,16 @@ shared_examples 'ok' do |headers, fmt| it "responds with 200 OK and Content-Type #{fmt::CONTENT_TYPE}" do registry.counter(:foo, docstring: 'foo counter').increment(by: 9) + + env['PATH_INFO'] = path + env['HTTP_ACCEPT'] = headers.values[0] if headers.values[0] - get '/metrics', nil, headers + status, _headers, _response = subject.call(env) + + expect(status).to eql(200) + expect(_headers['Content-Type']).to eql(fmt::CONTENT_TYPE) + expect(_response.first).to eql(fmt.marshal(registry)) - expect(last_response.status).to eql(200) - expect(last_response.header['Content-Type']).to eql(fmt::CONTENT_TYPE) - expect(last_response.body).to eql(fmt.marshal(registry)) end end @@ -49,11 +57,14 @@ it 'responds with 406 Not Acceptable' do message = 'Supported media types: text/plain' - get '/metrics', nil, headers + env['PATH_INFO'] = path + env['HTTP_ACCEPT'] = headers.values[0] if headers.values[0] + + status, _headers, _response = subject.call(env) - expect(last_response.status).to eql(406) - expect(last_response.header['Content-Type']).to eql('text/plain') - expect(last_response.body).to eql(message) + expect(status).to eql(406) + expect(_headers['Content-Type']).to eql('text/plain') + expect(_response.first).to eql(message) end end From 6cf11e6a19187f5286b50591d759f06aa8be6db1 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Fri, 29 Apr 2022 09:52:20 -0400 Subject: [PATCH 166/665] Add HTTP request collector middleware and metrics helper (cherry picked from commit d1adafab9eb285d921b0b2efde8e3c0e7826540e) --- lib/monitoring/metrics.rb | 59 ++++++++++++++++++ .../middleware/prometheus_collector.rb | 60 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 lib/monitoring/metrics.rb create mode 100644 lib/monitoring/middleware/prometheus_collector.rb diff --git a/lib/monitoring/metrics.rb b/lib/monitoring/metrics.rb new file mode 100644 index 0000000000..7cc0f66241 --- /dev/null +++ b/lib/monitoring/metrics.rb @@ -0,0 +1,59 @@ +module Monitoring + module Metrics + extend self + + def create_metric(metric, type) + case type.to_sym + when :counter + create_counter_metric(metric) + when :gauge + create_gauge_metric(metric) + when :histogram + create_histogram_metric(metric) + else + raise Exception.new "Invalid or missing metric type." + end + end + + private + + def create_gauge_metric(metric) + gauge = ::Prometheus::Client::Gauge.new( + metric.metric_name, + docstring: metric.docstring, + labels: metric.labels, + store_settings: { + aggregation: :most_recent + } + ) + metric.registry.register(gauge) + setup_subscriber(metric) + end + + def create_counter_metric(metric) + counter = ::Prometheus::Client::Counter.new( + metric.metric_name, + docstring: metric.docstring, + labels: metric.labels + ) + metric.registry.register(counter) + setup_subscriber(metric) + end + + def create_histogram_metric(metric) + histogram = ::Prometheus::Client::Histogram.new( + metric.metric_name, + docstring: metric.docstring, + labels: metric.labels + ) + metric.registry.register(histogram) + setup_subscriber(metric) + end + + def setup_subscriber(metric) + metric.pubsub.subscribe(metric.sub_event_name) do |payload| + metric.update(payload) + end + end + end +end diff --git a/lib/monitoring/middleware/prometheus_collector.rb b/lib/monitoring/middleware/prometheus_collector.rb new file mode 100644 index 0000000000..34d77a6a05 --- /dev/null +++ b/lib/monitoring/middleware/prometheus_collector.rb @@ -0,0 +1,60 @@ +require 'benchmark' +require_relative '../operations.rb' + +module Monitoring + module Middleware + class PrometheusCollector + attr_reader :app + + def initialize(app, options = {}) + @app = app + @pubsub = options[:pubsub] + end + + def call(env) # :nodoc: + trace(env) { @app.call(env) } + end + + protected + + # Trace HTTP requests + def trace(env) + response = nil + duration = Benchmark.realtime { response = yield } + record(env, response.first.to_s, duration) + return response + rescue => exception + operation = find_operation(env['REQUEST_METHOD'], env['PATH_INFO']) + @pubsub.publish( + "conjur.request_exception", + operation: operation, + exception: exception.class.name, + message: exception + ) + raise + end + + def record(env, code, duration) + operation = find_operation(env['REQUEST_METHOD'], env['PATH_INFO']) + @pubsub.publish( + "conjur.request", + code: code, + operation: operation, + duration: duration + ) + rescue + # TODO: log unexpected exception during request recording + nil + end + + def find_operation(method, path) + Monitoring::Metrics::OPERATIONS.each do |op| + if op[:method] == method && op[:pattern].match?(path) + return op[:operation] + end + end + return "unknown" + end + end + end +end From ef2c99ce19bc115ca3fe47a3cc2b1e505edf0b06 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Fri, 29 Apr 2022 09:53:14 -0400 Subject: [PATCH 167/665] Define request metrics, operations, and tests (cherry picked from commit 6cf75697bb5928319ee7c2a2b8dcd4759a175c8a) --- .../metrics/api_exception_counter.rb | 29 +++ lib/monitoring/metrics/api_request_counter.rb | 28 +++ .../metrics/api_request_histogram.rb | 27 +++ lib/monitoring/operations.rb | 227 ++++++++++++++++++ .../middleware/prometheus_collector_spec.rb | 107 +++++++++ 5 files changed, 418 insertions(+) create mode 100644 lib/monitoring/metrics/api_exception_counter.rb create mode 100644 lib/monitoring/metrics/api_request_counter.rb create mode 100644 lib/monitoring/metrics/api_request_histogram.rb create mode 100644 lib/monitoring/operations.rb create mode 100644 spec/lib/monitoring/middleware/prometheus_collector_spec.rb diff --git a/lib/monitoring/metrics/api_exception_counter.rb b/lib/monitoring/metrics/api_exception_counter.rb new file mode 100644 index 0000000000..f0cca45f49 --- /dev/null +++ b/lib/monitoring/metrics/api_exception_counter.rb @@ -0,0 +1,29 @@ +module Monitoring + module Metrics + class ApiExceptionCounter + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub + @metric_name = :conjur_http_server_request_exceptions_total + @docstring = 'The total number of API exceptions raised by Conjur.' + @labels = %i[operation exception message] + @sub_event_name = 'conjur.request_exception' + + # Create/register the metric + Metrics.create_metric(self, :counter) + end + + def update(payload) + metric = @registry.get(@metric_name) + update_labels = { + operation: payload[:operation], + exception: payload[:exception], + message: payload[:message] + } + metric.increment(labels: update_labels) + end + end + end +end diff --git a/lib/monitoring/metrics/api_request_counter.rb b/lib/monitoring/metrics/api_request_counter.rb new file mode 100644 index 0000000000..57d56c36c9 --- /dev/null +++ b/lib/monitoring/metrics/api_request_counter.rb @@ -0,0 +1,28 @@ +module Monitoring + module Metrics + class ApiRequestCounter + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub + @metric_name = :conjur_http_server_requests_total + @docstring = 'The total number of HTTP requests handled by Conjur.' + @labels = %i[code operation] + @sub_event_name = 'conjur.request' + + # Create/register the metric + Metrics.create_metric(self, :counter) + end + + def update(payload) + metric = @registry.get(@metric_name) + update_labels = { + code: payload[:code], + operation: payload[:operation] + } + metric.increment(labels: update_labels) + end + end + end +end diff --git a/lib/monitoring/metrics/api_request_histogram.rb b/lib/monitoring/metrics/api_request_histogram.rb new file mode 100644 index 0000000000..7c9fa9fd55 --- /dev/null +++ b/lib/monitoring/metrics/api_request_histogram.rb @@ -0,0 +1,27 @@ +module Monitoring + module Metrics + class ApiRequestHistogram + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub + @metric_name = :conjur_http_server_request_duration_seconds + @docstring = 'The HTTP response duration of requests handled by Conjur.' + @labels = %i[operation] + @sub_event_name = 'conjur.request' + + # Create/register the metric + Metrics.create_metric(self, :histogram) + end + + def update(payload) + metric = @registry.get(@metric_name) + update_labels = { + operation: payload[:operation] + } + metric.observe(payload[:duration], labels: update_labels) + end + end + end +end diff --git a/lib/monitoring/operations.rb b/lib/monitoring/operations.rb new file mode 100644 index 0000000000..b29a00af7a --- /dev/null +++ b/lib/monitoring/operations.rb @@ -0,0 +1,227 @@ +module Monitoring + module Metrics + OPERATIONS = [ + # AccountsApi (undocumented) + { + method: "POST", + pattern: /^(\/accounts)$/, + operation: "createAccount" + }, + { + method: "GET", + pattern: /^(\/accounts)$/, + operation: "getAccounts" + }, + { + method: "DELETE", + pattern: /^(\/accounts)(\/[^\/]+)$/, + operation: "deleteAccount" + }, + + # AuthenticationApi + { + method: "PUT", + pattern: /^(\/authn)(\/[^\/]+)(\/password)$/, + operation: "changePassword" + }, + { + method: "PATCH", + pattern: /^(\/authn-)([^\/]+)(\/[^\/]+){2,3}$/, + operation: "enableAuthenticatorInstance" + }, + { + method: "GET", + pattern: /^(\/authn)(\/[^\/]+)(\/login)$/, + operation: "getAPIKey" + }, + { + method: "GET", + pattern: /^(\/authn-ldap)(\/[^\/]+){2}(\/login)$/, + operation: "getAPIKeyViaLDAP" + }, + { + method: "POST", + pattern: /^(\/authn)(\/[^\/]+){2}(\/authenticate)$/, + operation: "getAccessToken" + }, + { + method: "POST", + pattern: /^(\/authn-iam)(\/[^\/]+){3}(\/authenticate)$/, + operation: "getAccessTokenViaAWS" + }, + { + method: "POST", + pattern: /^(\/authn-azure)(\/[^\/]+){3}(\/authenticate)$/, + operation: "getAccessTokenViaAzure" + }, + { + method: "POST", + pattern: /^(\/authn-gcp)(\/[^\/]+)(\/authenticate)$/, + operation: "getAccessTokenViaGCP" + }, + { + method: "POST", + pattern: /^(\/authn-k8s)(\/[^\/]+){3}(\/authenticate)$/, + operation: "getAccessTokenViaKubernetes" + }, + { + method: "POST", + pattern: /^(\/authn-ldap)(\/[^\/]+){3}(\/authenticate)$/, + operation: "getAccessTokenViaLDAP" + }, + { + method: "POST", + pattern: /^(\/authn-oidc)(\/[^\/]+){2}(\/authenticate)$/, + operation: "getAccessTokenViaOIDC" + }, + { + method: "POST", + pattern: /^(\/authn-jwt)(\/[^\/]+){2,3}(\/authenticate)$/, + operation: "getAccessTokenViaJWT" + }, + { + method: "POST", + pattern: /^(\/authn-k8s)(\/[^\/]+)(\/inject_client_cert)$/, + operation: "k8sInjectClientCert" + }, + { + method: "PUT", + pattern: /^(\/authn)(\/[^\/]+)(\/api_key)$/, + operation: "rotateAPIKey" + }, + + # CertificateAuthorityApi + { + method: "POST", + pattern: /^(\/ca)(\/[^\/]+){2}(\/sign)$/, + operation: "sign" + }, + + # HostFactoryApi + { + method: "POST", + pattern: /^(\/host_factories\/hosts)$/, + operation: "createHost" + }, + { + method: "POST", + pattern: /^(\/host_factory_tokens)$/, + operation: "createToken" + }, + { + method: "DELETE", + pattern: /^(\/host_factory_tokens)(\/[^\/]+)$/, + operation: "revokeToken" + }, + + # MetricsApi + { + method: "GET", + pattern: /^(\/metrics)$/, + operation: "getMetrics" + }, + + # PoliciesApi + { + method: "POST", + pattern: /^(\/policies)(\/[^\/]+){3}(\/.*)$/, + operation: "loadPolicy" + }, + { + method: "PUT", + pattern: /^(\/policies)(\/[^\/]+){3}(\/.*)$/, + operation: "replacePolicy" + }, + { + method: "PATCH", + pattern: /^(\/policies)(\/[^\/]+){3}(\/.*)$/, + operation: "updatePolicy" + }, + + # PublicKeysApi + { + method: "GET", + pattern: /^(\/public_keys)(\/[^\/]+){3}$/, + operation: "showPublicKeys" + }, + + # ResourcesApi + { + method: "GET", + pattern: /^(\/resources)(\/[^\/]+){3}(\/.*)$/, + operation: "showResource" + }, + { + method: "GET", + pattern: /^(\/resources)(\/[^\/]+){1}$/, + operation: "showResourcesForAccount" + }, + { + method: "GET", + pattern: /^(\/resources$)/, + operation: "showResourcesForAllAccounts" + }, + { + method: "GET", + pattern: /^(\/resources)(\/[^\/]+){2}$/, + operation: "showResourcesForKind" + }, + + # RolesApi + { + method: "POST", + pattern: /^(\/roles)(\/[^\/]+){3}$/, + operation: "addMemberToRole" + }, + { + method: "DELETE", + pattern: /^(\/roles)(\/[^\/]+){3}$/, + operation: "removeMemberFromRole" + }, + { + method: "GET", + pattern: /^(\/roles)(\/[^\/]+){3}$/, + operation: "showRole" + }, + + # SecretsApi + { + method: "POST", + pattern: /^(\/secrets)(\/[^\/]+){2}(\/.*)$/, + operation: "createSecret" + }, + { + method: "GET", + pattern: /^(\/secrets)(\/[^\/]+){3}$/, + operation: "getSecret" + }, + { + method: "GET", + pattern: /^(\/secrets)$/, + operation: "getSecrets" + }, + + # StatusApi + { + method: "GET", + pattern: /^(\/authenticators)$/, + operation: "getAuthenticators" + }, + { + method: "GET", + pattern: /^(\/authn-gcp)(\/[^\/]+)(\/status)$/, + operation: "getGCPAuthenticatorStatus" + }, + { + method: "GET", + pattern: /^(\/authn-)([^\/]+)(\/[^\/]+){2}(\/status)$/, + operation: "getServiceAuthenticatorStatus" + }, + { + method: "GET", + pattern: /^(\/whoami)$/, + operation: "whoAmI" + }, + ] + end +end diff --git a/spec/lib/monitoring/middleware/prometheus_collector_spec.rb b/spec/lib/monitoring/middleware/prometheus_collector_spec.rb new file mode 100644 index 0000000000..6b99699c58 --- /dev/null +++ b/spec/lib/monitoring/middleware/prometheus_collector_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' +require 'monitoring/middleware/prometheus_collector' +require 'monitoring/prometheus' +require 'monitoring/metrics' +Dir.glob(Rails.root + 'lib/monitoring/metrics/api_*.rb', &method(:require)) + + +describe Monitoring::Middleware::PrometheusCollector do + + # Clear out any existing subscribers and reset the data store + before do + pubsub.unsubscribe('conjur.request_exception') + pubsub.unsubscribe('conjur.request') + Monitoring::Prometheus.setup(registry: Prometheus::Client::Registry.new, metrics: metrics) + end + + let(:metrics) { [ + Monitoring::Metrics::ApiRequestCounter.new, + Monitoring::Metrics::ApiRequestHistogram.new, + Monitoring::Metrics::ApiExceptionCounter.new + ] } + + let(:registry) { Monitoring::Prometheus.registry } + + let(:request_counter_metric) { registry.get(:conjur_http_server_requests_total) } + + let(:request_duration_metric) { registry.get(:conjur_http_server_request_duration_seconds) } + + let(:env) { Rack::MockRequest.env_for } + + let(:app) do + app = ->(env) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } + end + + let(:pubsub) { Monitoring::PubSub.instance } + + let(:options) { { pubsub: pubsub } } + + subject { described_class.new(app, **options) } + + it 'returns the app response' do + env['PATH_INFO'] = "/foo" + status, _headers, _response = subject.call(env) + + expect(status).to eql(200) + expect(_response.first).to eql('OK') + end + + it 'traces request information' do + expect(Benchmark).to receive(:realtime).and_yield.and_return(0.2) + + env['PATH_INFO'] = "/foo" + status, _headers, _response = subject.call(env) + + labels = { operation: 'unknown', code: '200' } + expect(request_counter_metric.get(labels: labels)).to eql(1.0) + + labels = { operation: 'unknown' } + expect(request_duration_metric.get(labels: labels)).to include("0.1" => 0, "0.25" => 1) + end + + it 'stores a known operation ID in the metrics store' do + expect(Benchmark).to receive(:realtime).and_yield.and_return(0.2) + + env['PATH_INFO'] = "/whoami" + status, _headers, _response = subject.call(env) + + labels = { operation: 'whoAmI', code: '200' } + expect(request_counter_metric.get(labels: labels)).to eql(1.0) + + labels = { operation: 'whoAmI' } + expect(request_duration_metric.get(labels: labels)).to include("0.1" => 0, "0.25" => 1) + end + + context 'when the app raises an exception' do + + let(:dummy_error) { RuntimeError.new('Dummy error from tests') } + + let(:request_exception_metric) { registry.get(:conjur_http_server_request_exceptions_total) } + + let(:app) do + app = ->(env) { + raise dummy_error if env['PATH_INFO'] == '/broken' + [200, { 'Content-Type' => 'text/html' }, ['OK']] + } + end + + subject { described_class.new(app, **options) } + + before do + subject.call(env) + end + + it 'traces exceptions' do + env['PATH_INFO'] = '/broken' + expect { subject.call(env) }.to raise_error(RuntimeError) + + labels = { + operation: 'unknown', + exception: 'RuntimeError', + message: 'Dummy error from tests' + } + + expect(request_exception_metric.get(labels: labels)).to eql(1.0) + end + end +end From de0169f6b80315beb5470463556d914ff6b515f6 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Fri, 29 Apr 2022 09:53:40 -0400 Subject: [PATCH 168/665] Update Prometheus initializer and cleanup (cherry picked from commit 5aa7535c1492578f2d0c5ed6f663d1f2a5d71add) --- config/initializers/prometheus.rb | 26 ++++++++++++++----- .../middleware/prometheus_exporter.rb | 2 +- lib/monitoring/prometheus.rb | 5 ---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index ad711aa2c7..13fb139a87 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -1,7 +1,21 @@ - # If using Prometheus telemetry, we want to ensure that the middleware +if Rails.application.config.conjur_config.telemetry_enabled + require 'monitoring/prometheus' + require 'monitoring/metrics' + require 'monitoring/pub_sub' + # Require all defined metrics + Dir.glob(Rails.root + 'lib/monitoring/metrics/*.rb', &method(:require)) + + # Register new metrics and setup the Prometheus client store + metrics = [ + Monitoring::Metrics::ApiRequestCounter.new, + Monitoring::Metrics::ApiRequestHistogram.new, + Monitoring::Metrics::ApiExceptionCounter.new + ] + Monitoring::Prometheus.setup(metrics: metrics) + + # Initialize Prometheus middleware. We want to ensure that the middleware # which collects and exports metrics is loaded at the start of the - # middleware chain to prevent any modifications to the incoming requests - if Rails.application.config.conjur_config.telemetry_enabled - Monitoring::Prometheus.setup - Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: Monitoring::Prometheus.registry, path: '/metrics') - end \ No newline at end of file + # middleware chain to prevent any modifications to incoming HTTP requests + Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: Monitoring::Prometheus.registry, path: '/metrics') + Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusCollector, pubsub: Monitoring::PubSub.instance) +end diff --git a/lib/monitoring/middleware/prometheus_exporter.rb b/lib/monitoring/middleware/prometheus_exporter.rb index 83402c814b..a233c19662 100644 --- a/lib/monitoring/middleware/prometheus_exporter.rb +++ b/lib/monitoring/middleware/prometheus_exporter.rb @@ -10,7 +10,7 @@ module Middleware # under `/metrics`. Use the `:registry` and `:path` options to change the # defaults. class PrometheusExporter - attr_reader :app, :path, :registry + attr_reader :app FORMATS = [::Prometheus::Client::Formats::Text].freeze FALLBACK = ::Prometheus::Client::Formats::Text diff --git a/lib/monitoring/prometheus.rb b/lib/monitoring/prometheus.rb index d6c8cfb9dd..0630113f08 100644 --- a/lib/monitoring/prometheus.rb +++ b/lib/monitoring/prometheus.rb @@ -8,7 +8,6 @@ module Prometheus def setup(options = {}) @registry = options[:registry] || ::Prometheus::Client::Registry.new - @metrics_prefix = options[:metrics_prefix] || "conjur_http_server" @metrics_dir_path = ENV['CONJUR_METRICS_DIR'] || '/tmp/prometheus' @pubsub = options[:pubsub] || PubSub.instance @@ -26,10 +25,6 @@ def registry @registry end - def metrics_prefix - @metrics_prefix - end - protected def clear_data_store From 1ad3f50773cc74de3d8db8326bfc0698a08156b1 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 25 May 2022 13:03:24 -0400 Subject: [PATCH 169/665] Add policy resource metric and pub/sub events (cherry picked from commit c88ee57c9e6d824a283eea62aab7c375bc752b02) --- app/controllers/policies_controller.rb | 6 +++- config/initializers/prometheus.rb | 6 ++-- .../metrics/policy_resouce_gauge.rb | 30 +++++++++++++++++++ lib/monitoring/query_helper.rb | 17 +++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 lib/monitoring/metrics/policy_resouce_gauge.rb create mode 100644 lib/monitoring/query_helper.rb diff --git a/app/controllers/policies_controller.rb b/app/controllers/policies_controller.rb index b28ff8e23d..bf3e7da476 100644 --- a/app/controllers/policies_controller.rb +++ b/app/controllers/policies_controller.rb @@ -3,9 +3,9 @@ class PoliciesController < RestController include FindResource include AuthorizeResource - before_action :current_user before_action :find_or_create_root_policy + after_action :publish_event, if: -> { response.successful? } rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load @@ -127,6 +127,10 @@ def create_roles(actor_roles) end end + def publish_event + Monitoring::PubSub.instance.publish('conjur.policy_loaded') + end + # If annotation authn/api-key changed from false to true during policy load, # the DB trigger set it to APIKEY. We need to update the api_key to a real one. def update_roles diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index 13fb139a87..d6ba14a73a 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -1,7 +1,8 @@ +require 'monitoring/pub_sub' + if Rails.application.config.conjur_config.telemetry_enabled require 'monitoring/prometheus' require 'monitoring/metrics' - require 'monitoring/pub_sub' # Require all defined metrics Dir.glob(Rails.root + 'lib/monitoring/metrics/*.rb', &method(:require)) @@ -9,7 +10,8 @@ metrics = [ Monitoring::Metrics::ApiRequestCounter.new, Monitoring::Metrics::ApiRequestHistogram.new, - Monitoring::Metrics::ApiExceptionCounter.new + Monitoring::Metrics::ApiExceptionCounter.new, + Monitoring::Metrics::PolicyResourceGauge.new ] Monitoring::Prometheus.setup(metrics: metrics) diff --git a/lib/monitoring/metrics/policy_resouce_gauge.rb b/lib/monitoring/metrics/policy_resouce_gauge.rb new file mode 100644 index 0000000000..e492ecf5d7 --- /dev/null +++ b/lib/monitoring/metrics/policy_resouce_gauge.rb @@ -0,0 +1,30 @@ +module Monitoring + module Metrics + class PolicyResourceGauge + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub + @metric_name = :conjur_resource_count + @docstring = 'Number of resources in Conjur database' + @labels = %i[kind] + @sub_event_name = 'conjur.resource_count_update' + @throttle = true + + # Create/register the metric + Metrics.create_metric(self, :gauge) + + # Run update to set the initial counts on startup + update + end + + def update(*payload) + metric = @registry.get(@metric_name) + Monitoring::QueryHelper.instance.policy_resource_counts.each do |kind, value| + metric.set(value, labels: { kind: kind }) + end + end + end + end +end diff --git a/lib/monitoring/query_helper.rb b/lib/monitoring/query_helper.rb new file mode 100644 index 0000000000..e88b4fa4f5 --- /dev/null +++ b/lib/monitoring/query_helper.rb @@ -0,0 +1,17 @@ +require 'singleton' + +module Monitoring + class QueryHelper + include Singleton + + def policy_resource_counts() + counts = {} + kind = ::Sequel.function(:kind, :resource_id) + Resource.group_and_count(kind).each do |record| + counts[record[:kind]] = record[:count] + end + counts + end + + end +end From 56bae7fc026d81c15955fdf529eb68e454f5332a Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 25 May 2022 13:04:34 -0400 Subject: [PATCH 170/665] Add stubs for future throttling of metric updates (cherry picked from commit fe850fd47df168eb2af99be3ba9fa831ec706385) --- lib/monitoring/metrics.rb | 8 ++++++++ lib/monitoring/metrics/api_exception_counter.rb | 2 +- lib/monitoring/metrics/api_request_counter.rb | 2 +- lib/monitoring/metrics/api_request_histogram.rb | 2 +- lib/monitoring/operations.rb | 6 +++--- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/monitoring/metrics.rb b/lib/monitoring/metrics.rb index 7cc0f66241..d3497bd015 100644 --- a/lib/monitoring/metrics.rb +++ b/lib/monitoring/metrics.rb @@ -54,6 +54,14 @@ def setup_subscriber(metric) metric.pubsub.subscribe(metric.sub_event_name) do |payload| metric.update(payload) end + throttle_policy_event(metric) unless !metric.throttle + end + + def throttle_policy_event(metric) + # TODO: revisit throttling for metrics which execute DB queries + metric.pubsub.subscribe('conjur.policy_loaded') do + metric.pubsub.publish(metric.sub_event_name) + end end end end diff --git a/lib/monitoring/metrics/api_exception_counter.rb b/lib/monitoring/metrics/api_exception_counter.rb index f0cca45f49..9d879ae9e8 100644 --- a/lib/monitoring/metrics/api_exception_counter.rb +++ b/lib/monitoring/metrics/api_exception_counter.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class ApiExceptionCounter - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle def setup(registry, pubsub) @registry = registry diff --git a/lib/monitoring/metrics/api_request_counter.rb b/lib/monitoring/metrics/api_request_counter.rb index 57d56c36c9..86b17e1d25 100644 --- a/lib/monitoring/metrics/api_request_counter.rb +++ b/lib/monitoring/metrics/api_request_counter.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class ApiRequestCounter - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle def setup(registry, pubsub) @registry = registry diff --git a/lib/monitoring/metrics/api_request_histogram.rb b/lib/monitoring/metrics/api_request_histogram.rb index 7c9fa9fd55..3ccf2e5839 100644 --- a/lib/monitoring/metrics/api_request_histogram.rb +++ b/lib/monitoring/metrics/api_request_histogram.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class ApiRequestHistogram - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle def setup(registry, pubsub) @registry = registry diff --git a/lib/monitoring/operations.rb b/lib/monitoring/operations.rb index b29a00af7a..98e9c3240a 100644 --- a/lib/monitoring/operations.rb +++ b/lib/monitoring/operations.rb @@ -124,17 +124,17 @@ module Metrics # PoliciesApi { method: "POST", - pattern: /^(\/policies)(\/[^\/]+){3}(\/.*)$/, + pattern: /^(\/policies)(\/[^\/]+){2,3}(\/.*)$/, operation: "loadPolicy" }, { method: "PUT", - pattern: /^(\/policies)(\/[^\/]+){3}(\/.*)$/, + pattern: /^(\/policies)(\/[^\/]+){2,3}(\/.*)$/, operation: "replacePolicy" }, { method: "PATCH", - pattern: /^(\/policies)(\/[^\/]+){3}(\/.*)$/, + pattern: /^(\/policies)(\/[^\/]+){2,3}(\/.*)$/, operation: "updatePolicy" }, From 39820b49297d22695cd0dbc6bb2e55d18382c16e Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 25 May 2022 13:04:49 -0400 Subject: [PATCH 171/665] Policy metric tests (cherry picked from commit b38d4361708be2d8df69f44692d4cca8c1867f20) --- .../monitoring/metrics/policy_metrics_spec.rb | 95 +++++++++++++++++++ spec/lib/monitoring/query_helper_spec.rb | 11 +++ 2 files changed, 106 insertions(+) create mode 100644 spec/lib/monitoring/metrics/policy_metrics_spec.rb create mode 100644 spec/lib/monitoring/query_helper_spec.rb diff --git a/spec/lib/monitoring/metrics/policy_metrics_spec.rb b/spec/lib/monitoring/metrics/policy_metrics_spec.rb new file mode 100644 index 0000000000..865e97108a --- /dev/null +++ b/spec/lib/monitoring/metrics/policy_metrics_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' +require 'monitoring/query_helper' +Dir.glob(Rails.root + 'lib/monitoring/metrics/policy_*.rb', &method(:require)) + +describe 'policy metrics', type: :request do + + before do + pubsub.unsubscribe('conjur.policy_loaded') + pubsub.unsubscribe('conjur.resource_count_update') + + @resource_metric = Monitoring::Metrics::PolicyResourceGauge.new + + # Clear and setup the Prometheus client store + Monitoring::Prometheus.setup( + registry: Prometheus::Client::Registry.new, + metrics: metrics + ) + + Slosilo["authn:rspec"] ||= Slosilo::Key.new + end + + def headers_with_auth(payload) + token_auth_header.merge({ 'RAW_POST_DATA' => payload }) + end + + let(:registry) { Monitoring::Prometheus.registry } + + let(:metrics) { [ @resource_metric ] } + + let(:pubsub) { Monitoring::PubSub.instance } + + let(:policy_load_event_name) { 'conjur.policy_loaded' } + + let(:policies_url) { '/policies/rspec/policy/root' } + + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + + let(:token_auth_header) do + bearer_token = Slosilo["authn:rspec"].signed_token(current_user.login) + token_auth_str = + "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" + { 'HTTP_AUTHORIZATION' => token_auth_str } + end + + context 'when a policy is loaded' do + + it 'publishes a policy load event (POST)' do + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original + + expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) + post(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'publishes a policy load event (PUT)' do + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original + + expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) + put(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'publishes a policy load event (PATCH)' do + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original + + expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) + patch(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'calls update on the correct metric' do + expect(@resource_metric).to receive(:update) + post(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'updates the registry' do + post(policies_url, env: headers_with_auth('[!variable added]')) + + gauge_metric = registry.get(:conjur_resource_count) + expect(gauge_metric.get(labels: { kind: 'variable' })).to eql(1.0) + end + + end + + context 'when multiple policies are loaded' do + + # Revisit this test when update throttling has been implemented + xit 'throttles policy events' do + expect(@resource_metric).to receive(:update).at_most(2).times + post(policies_url, env: headers_with_auth('[!variable test1]')) + post(policies_url, env: headers_with_auth('[!variable test2]')) + post(policies_url, env: headers_with_auth('[!variable test3]')) + post(policies_url, env: headers_with_auth('[!variable test4]')) + post(policies_url, env: headers_with_auth('[!variable test5]')) + end + + end +end diff --git a/spec/lib/monitoring/query_helper_spec.rb b/spec/lib/monitoring/query_helper_spec.rb new file mode 100644 index 0000000000..30caa6d2aa --- /dev/null +++ b/spec/lib/monitoring/query_helper_spec.rb @@ -0,0 +1,11 @@ +require 'monitoring/query_helper' + +describe Monitoring::QueryHelper do + let(:queryhelper) { Monitoring::QueryHelper.instance } + + it 'returns policy resource counts' do + resource_counts = queryhelper.policy_resource_counts + expect(resource_counts).not_to be_empty + end + +end From 01eac5ee3cca419c59ceee28f400d8da8ae2ad72 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Thu, 26 May 2022 09:08:02 -0400 Subject: [PATCH 172/665] Add policy role metric and tests (cherry picked from commit b77baaae9b5b030183689725e3a5d942adca2a74) --- config/initializers/prometheus.rb | 3 +- lib/monitoring/metrics/policy_role_gauge.rb | 30 +++++++++++++++++++ lib/monitoring/query_helper.rb | 10 ++++++- .../monitoring/metrics/policy_metrics_spec.rb | 26 ++++++++++++---- spec/lib/monitoring/query_helper_spec.rb | 5 ++++ 5 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 lib/monitoring/metrics/policy_role_gauge.rb diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index d6ba14a73a..a117d25658 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -11,7 +11,8 @@ Monitoring::Metrics::ApiRequestCounter.new, Monitoring::Metrics::ApiRequestHistogram.new, Monitoring::Metrics::ApiExceptionCounter.new, - Monitoring::Metrics::PolicyResourceGauge.new + Monitoring::Metrics::PolicyResourceGauge.new, + Monitoring::Metrics::PolicyRoleGauge.new ] Monitoring::Prometheus.setup(metrics: metrics) diff --git a/lib/monitoring/metrics/policy_role_gauge.rb b/lib/monitoring/metrics/policy_role_gauge.rb new file mode 100644 index 0000000000..483747cbc0 --- /dev/null +++ b/lib/monitoring/metrics/policy_role_gauge.rb @@ -0,0 +1,30 @@ +module Monitoring + module Metrics + class PolicyRoleGauge + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub + @metric_name = :conjur_role_count + @docstring = 'Number of roles in Conjur database' + @labels = %i[kind] + @sub_event_name = 'conjur.role_count_update' + @throttle = true + + # Create/register the metric + Metrics.create_metric(self, :gauge) + + # Run update to set the initial counts on startup + update + end + + def update(*payload) + metric = @registry.get(@metric_name) + Monitoring::QueryHelper.instance.policy_role_counts.each do |kind, value| + metric.set(value, labels: { kind: kind }) + end + end + end + end +end diff --git a/lib/monitoring/query_helper.rb b/lib/monitoring/query_helper.rb index e88b4fa4f5..7077a03557 100644 --- a/lib/monitoring/query_helper.rb +++ b/lib/monitoring/query_helper.rb @@ -4,7 +4,7 @@ module Monitoring class QueryHelper include Singleton - def policy_resource_counts() + def policy_resource_counts counts = {} kind = ::Sequel.function(:kind, :resource_id) Resource.group_and_count(kind).each do |record| @@ -13,5 +13,13 @@ def policy_resource_counts() counts end + def policy_role_counts + counts = {} + kind = ::Sequel.function(:kind, :role_id) + Role.group_and_count(kind).each do |record| + counts[record[:kind]] = record[:count] + end + counts + end end end diff --git a/spec/lib/monitoring/metrics/policy_metrics_spec.rb b/spec/lib/monitoring/metrics/policy_metrics_spec.rb index 865e97108a..506372f874 100644 --- a/spec/lib/monitoring/metrics/policy_metrics_spec.rb +++ b/spec/lib/monitoring/metrics/policy_metrics_spec.rb @@ -7,8 +7,10 @@ before do pubsub.unsubscribe('conjur.policy_loaded') pubsub.unsubscribe('conjur.resource_count_update') + pubsub.unsubscribe('conjur.role_count_update') @resource_metric = Monitoring::Metrics::PolicyResourceGauge.new + @role_metric = Monitoring::Metrics::PolicyRoleGauge.new # Clear and setup the Prometheus client store Monitoring::Prometheus.setup( @@ -25,7 +27,7 @@ def headers_with_auth(payload) let(:registry) { Monitoring::Prometheus.registry } - let(:metrics) { [ @resource_metric ] } + let(:metrics) { [ @resource_metric, @role_metric ] } let(:pubsub) { Monitoring::PubSub.instance } @@ -48,6 +50,8 @@ def headers_with_auth(payload) expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) + expect(Monitoring::PubSub.instance).to receive(:publish).with(@role_metric.sub_event_name) + post(policies_url, env: headers_with_auth('[!variable test]')) end @@ -55,6 +59,8 @@ def headers_with_auth(payload) expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) + expect(Monitoring::PubSub.instance).to receive(:publish).with(@role_metric.sub_event_name) + put(policies_url, env: headers_with_auth('[!variable test]')) end @@ -62,19 +68,29 @@ def headers_with_auth(payload) expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) + expect(Monitoring::PubSub.instance).to receive(:publish).with(@role_metric.sub_event_name) + patch(policies_url, env: headers_with_auth('[!variable test]')) end - it 'calls update on the correct metric' do + it 'calls update on the correct metrics' do expect(@resource_metric).to receive(:update) + expect(@role_metric).to receive(:update) + post(policies_url, env: headers_with_auth('[!variable test]')) end it 'updates the registry' do - post(policies_url, env: headers_with_auth('[!variable added]')) + resources_before = registry.get(:conjur_resource_count).get(labels: { kind: 'group' }) + roles_before = registry.get(:conjur_role_count).get(labels: { kind: 'group' }) + + post(policies_url, env: headers_with_auth('[!group test]')) + + resources_after = registry.get(:conjur_resource_count).get(labels: { kind: 'group' }) + roles_after = registry.get(:conjur_role_count).get(labels: { kind: 'group' }) - gauge_metric = registry.get(:conjur_resource_count) - expect(gauge_metric.get(labels: { kind: 'variable' })).to eql(1.0) + expect(resources_after - resources_before).to eql(1.0) + expect(roles_after - roles_before).to eql(1.0) end end diff --git a/spec/lib/monitoring/query_helper_spec.rb b/spec/lib/monitoring/query_helper_spec.rb index 30caa6d2aa..271c7ce1fc 100644 --- a/spec/lib/monitoring/query_helper_spec.rb +++ b/spec/lib/monitoring/query_helper_spec.rb @@ -8,4 +8,9 @@ expect(resource_counts).not_to be_empty end + it 'returns policy role counts' do + role_counts = queryhelper.policy_role_counts + expect(role_counts).not_to be_empty + end + end From d6a955fa95c590779885a946d1803c0d0d02109d Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 14 Sep 2022 10:37:51 -0600 Subject: [PATCH 173/665] Add authenticator metric and tests (cherry picked from commit be2d9ef85b6c288991751c01be9e2bf3a049ebcc) --- config/initializers/prometheus.rb | 6 +- lib/monitoring/metrics.rb | 4 +- lib/monitoring/metrics/authenticator_gauge.rb | 62 ++++++++++ .../metrics/authenticator_metrics_spec.rb | 109 ++++++++++++++++++ .../monitoring/metrics/policy_metrics_spec.rb | 8 +- spec/lib/monitoring/query_helper_spec.rb | 27 ++++- 6 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 lib/monitoring/metrics/authenticator_gauge.rb create mode 100644 spec/lib/monitoring/metrics/authenticator_metrics_spec.rb diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index a117d25658..3e803982bd 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -6,13 +6,17 @@ # Require all defined metrics Dir.glob(Rails.root + 'lib/monitoring/metrics/*.rb', &method(:require)) + # Load the authentication module early so that telemetry can see which authenticators are installed on startup + Dir.glob(Rails.root + 'app/domain/authentication/**/*.rb', &method(:require)) + # Register new metrics and setup the Prometheus client store metrics = [ Monitoring::Metrics::ApiRequestCounter.new, Monitoring::Metrics::ApiRequestHistogram.new, Monitoring::Metrics::ApiExceptionCounter.new, Monitoring::Metrics::PolicyResourceGauge.new, - Monitoring::Metrics::PolicyRoleGauge.new + Monitoring::Metrics::PolicyRoleGauge.new, + Monitoring::Metrics::AuthenticatorGauge.new, ] Monitoring::Prometheus.setup(metrics: metrics) diff --git a/lib/monitoring/metrics.rb b/lib/monitoring/metrics.rb index d3497bd015..99705fbaf3 100644 --- a/lib/monitoring/metrics.rb +++ b/lib/monitoring/metrics.rb @@ -58,7 +58,9 @@ def setup_subscriber(metric) end def throttle_policy_event(metric) - # TODO: revisit throttling for metrics which execute DB queries + # TODO: Revisit throttling for metrics which execute DB queries. + # Currently this method is only used to group events that should run + # when a policy is loaded. It does not throttle the amount of updates. metric.pubsub.subscribe('conjur.policy_loaded') do metric.pubsub.publish(metric.sub_event_name) end diff --git a/lib/monitoring/metrics/authenticator_gauge.rb b/lib/monitoring/metrics/authenticator_gauge.rb new file mode 100644 index 0000000000..8d1a57459a --- /dev/null +++ b/lib/monitoring/metrics/authenticator_gauge.rb @@ -0,0 +1,62 @@ +module Monitoring + module Metrics + class AuthenticatorGauge + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub + @metric_name = :conjur_server_authenticator + @docstring = 'Number of authenticators enabled' + @labels = [:type, :status] + @sub_event_name = 'conjur.authenticator_count_update' + @throttle = true + + # Create/register the metric + Metrics.create_metric(self, :gauge) + + # Run update to set the initial counts on startup + update + end + + def update(*payload) + metric = @registry.get(@metric_name) + update_enabled_authenticators(metric) + update_installed_authenticators(metric) + update_configured_authenticators(metric) + end + + def update_enabled_authenticators(metric) + enabled_authenticators = Authentication::InstalledAuthenticators.enabled_authenticators + enabled_authenticator_counts = get_authenticator_counts(enabled_authenticators) + enabled_authenticator_counts.each do |type, count| + metric.set(count, labels: { type: type, status: 'enabled'}) + end + end + + def update_installed_authenticators(metric) + installed_authenticators = Authentication::InstalledAuthenticators.authenticators(ENV).keys + installed_authenticators.each do |type| + metric.set(1, labels: { type: type, status: 'installed'}) + end + end + + def update_configured_authenticators(metric) + configured_authenticators = Authentication::InstalledAuthenticators.configured_authenticators + configured_authenticator_counts = get_authenticator_counts(configured_authenticators) + configured_authenticator_counts.each do |type, count| + metric.set(count, labels: { type: type, status: 'configured'}) + end + end + + def get_authenticator_counts(authenticators) + authenticator_counts = {} + authenticators.each do |authenticator| + type = authenticator.split('/')[0] + authenticator_counts[type] ? authenticator_counts[type] += 1 : authenticator_counts[type] = 1 + end + return authenticator_counts + end + end + end +end diff --git a/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb b/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb new file mode 100644 index 0000000000..731b8e86ff --- /dev/null +++ b/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' +require 'monitoring/query_helper' +require 'monitoring/metrics/authenticator_gauge' +Dir.glob(Rails.root + 'lib/monitoring/metrics/authenticator_.rb', &method(:require)) + +describe 'authenticator metrics', type: :request do + + before do + pubsub.unsubscribe('conjur.policy_loaded') + pubsub.unsubscribe('conjur.authenticator_count_update') + + @authenticator_metric = Monitoring::Metrics::AuthenticatorGauge.new + + # Clear and setup the Prometheus client store + Monitoring::Prometheus.setup( + registry: Prometheus::Client::Registry.new, + metrics: metrics + ) + + Slosilo["authn:rspec"] ||= Slosilo::Key.new + end + + def headers_with_auth(payload) + token_auth_header.merge({ 'RAW_POST_DATA' => payload }) + end + + let(:registry) { Monitoring::Prometheus.registry } + + let(:metrics) { [ @authenticator_metric ] } + + let(:pubsub) { Monitoring::PubSub.instance } + + let(:policy_load_event_name) { 'conjur.policy_loaded' } + + let(:policies_url) { '/policies/rspec/policy/root' } + + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + + let(:token_auth_header) do + bearer_token = Slosilo["authn:rspec"].signed_token(current_user.login) + token_auth_str = + "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" + { 'HTTP_AUTHORIZATION' => token_auth_str } + end + + context 'when a policy is loaded' do + + it 'publishes a policy load event (POST)' do + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original + expect(Monitoring::PubSub.instance).to receive(:publish).with(@authenticator_metric.sub_event_name) + + post(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'publishes a policy load event (PUT)' do + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original + expect(Monitoring::PubSub.instance).to receive(:publish).with(@authenticator_metric.sub_event_name) + + put(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'publishes a policy load event (PATCH)' do + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original + expect(Monitoring::PubSub.instance).to receive(:publish).with(@authenticator_metric.sub_event_name) + + patch(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'calls update on the correct metrics' do + expect(@authenticator_metric).to receive(:update) + + post(policies_url, env: headers_with_auth('[!variable test]')) + end + + it 'updates the registry' do + authenticators_before = registry.get(@authenticator_metric.metric_name).get(labels: { type: 'authn-jwt', status: 'configured' }) + post(policies_url, env: headers_with_auth( + <<~POLICY + - !policy + id: conjur/authn-jwt/sysadmins + body: + - !webservice + + - !group + id: clients + + - !permit + resource: !webservice + privilege: [ read, authenticate ] + role: !group clients + POLICY + )) + + authenticators_after = registry.get(@authenticator_metric.metric_name).get(labels: { type: 'authn-jwt', status: 'configured' }) + + expect(authenticators_after - authenticators_before).to eql(1.0) + end + + it 'trims the authenticator service id' do + authenticators = ['authn', 'authn-iam/some-service/id', 'authn-oidc/some/nested/service-id', 'authn-oidc/some/other/service-id'] + authenticator_counts = @authenticator_metric.get_authenticator_counts(authenticators) + + expect(authenticator_counts['authn']).to eql(1) + expect(authenticator_counts['authn-iam']).to eql(1) + expect(authenticator_counts['authn-oidc']).to eql(2) + end + + end +end diff --git a/spec/lib/monitoring/metrics/policy_metrics_spec.rb b/spec/lib/monitoring/metrics/policy_metrics_spec.rb index 506372f874..c25063ccea 100644 --- a/spec/lib/monitoring/metrics/policy_metrics_spec.rb +++ b/spec/lib/monitoring/metrics/policy_metrics_spec.rb @@ -81,13 +81,13 @@ def headers_with_auth(payload) end it 'updates the registry' do - resources_before = registry.get(:conjur_resource_count).get(labels: { kind: 'group' }) - roles_before = registry.get(:conjur_role_count).get(labels: { kind: 'group' }) + resources_before = registry.get(@resource_metric.metric_name).get(labels: { kind: 'group' }) + roles_before = registry.get(@role_metric.metric_name).get(labels: { kind: 'group' }) post(policies_url, env: headers_with_auth('[!group test]')) - resources_after = registry.get(:conjur_resource_count).get(labels: { kind: 'group' }) - roles_after = registry.get(:conjur_role_count).get(labels: { kind: 'group' }) + resources_after = registry.get(@resource_metric.metric_name).get(labels: { kind: 'group' }) + roles_after = registry.get(@role_metric.metric_name).get(labels: { kind: 'group' }) expect(resources_after - resources_before).to eql(1.0) expect(roles_after - roles_before).to eql(1.0) diff --git a/spec/lib/monitoring/query_helper_spec.rb b/spec/lib/monitoring/query_helper_spec.rb index 271c7ce1fc..bbbb37c5d2 100644 --- a/spec/lib/monitoring/query_helper_spec.rb +++ b/spec/lib/monitoring/query_helper_spec.rb @@ -1,16 +1,37 @@ require 'monitoring/query_helper' +require 'spec_helper' -describe Monitoring::QueryHelper do +describe Monitoring::QueryHelper, type: :request do let(:queryhelper) { Monitoring::QueryHelper.instance } + let(:policies_url) { '/policies/rspec/policy/root' } + + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + + let(:token_auth_header) do + bearer_token = Slosilo["authn:rspec"].signed_token(current_user.login) + token_auth_str = + "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" + { 'HTTP_AUTHORIZATION' => token_auth_str } + end + + def headers_with_auth(payload) + token_auth_header.merge({ 'RAW_POST_DATA' => payload }) + end + + before do + Slosilo["authn:rspec"] ||= Slosilo::Key.new + post(policies_url, env: headers_with_auth('[!group test]')) + end + it 'returns policy resource counts' do resource_counts = queryhelper.policy_resource_counts - expect(resource_counts).not_to be_empty + expect(resource_counts['group']).to equal(1) end it 'returns policy role counts' do role_counts = queryhelper.policy_role_counts - expect(role_counts).not_to be_empty + expect(role_counts['group']).to equal(1) end end From ed146e2f7c7bcd957a68a41518d71169413b206f Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Wed, 28 Sep 2022 17:08:44 +0300 Subject: [PATCH 174/665] Add prometheus service and config to dev/start script (cherry picked from commit 81287bfc8c263facfc9e2b0508eab11d400f01ee) --- dev/docker-compose.yml | 13 +++++++++++++ dev/files/prometheus/alerts.yml | 27 +++++++++++++++++++++++++++ dev/files/prometheus/prometheus.yml | 21 +++++++++++++++++++++ dev/start | 1 + 4 files changed, 62 insertions(+) create mode 100644 dev/files/prometheus/alerts.yml create mode 100644 dev/files/prometheus/prometheus.yml diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index c168d67b73..9e9e207efd 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -183,6 +183,19 @@ services: volumes: - ../ci/jwt/:/usr/src/jwks/ + prometheus: + image: prom/prometheus + volumes: + - ./files/prometheus:/etc/prometheus + ports: + - 9090:9090 + command: --web.enable-lifecycle --config.file=/etc/prometheus/prometheus.yml + + # Node exporter provides CPU and Memory metrics to Prometheus for the Docker + # host machine. + node-exporter: + image: quay.io/prometheus/node-exporter:latest + ephemeral-secrets: image: 238637036211.dkr.ecr.us-east-1.amazonaws.com/mgmt-ephemeral-service-dev-repository-conjur:latest ports: diff --git a/dev/files/prometheus/alerts.yml b/dev/files/prometheus/alerts.yml new file mode 100644 index 0000000000..1c2a0b683f --- /dev/null +++ b/dev/files/prometheus/alerts.yml @@ -0,0 +1,27 @@ +groups: + - name: Hardware alerts + rules: + - alert: Node down + expr: up{job="node_exporter"} == 0 + for: 3m + labels: + severity: warning + annotations: + title: Node {{ $labels.instance }} is down + description: Failed to scrape {{ $labels.job }} on {{ $labels.instance }} for more than 3 minutes. Node seems down. + + - alert: Low free space + expr: (node_filesystem_free{mountpoint !~ "/mnt.*"} / node_filesystem_size{mountpoint !~ "/mnt.*"} * 100) < 15 + for: 1m + labels: + severity: warning + annotations: + title: Low free space on {{ $labels.instance }} + description: On {{ $labels.instance }} device {{ $labels.device }} mounted on {{ $labels.mountpoint }} has low free space of {{ $value }}% + + - alert: Conjur Down + expr: up{job="conjur"} < 1 + for: 1m + annotations: + title: Conjur is down + description: Failed to scrape Conjur on {{ $labels.instance }} for more than 1 minute. Node seems down. diff --git a/dev/files/prometheus/prometheus.yml b/dev/files/prometheus/prometheus.yml new file mode 100644 index 0000000000..d9743c256b --- /dev/null +++ b/dev/files/prometheus/prometheus.yml @@ -0,0 +1,21 @@ +global: + scrape_interval: "15s" + +rule_files: + - alert.yml + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: + - "localhost:9090" + + - job_name: "node-exporter" + static_configs: + - targets: + - "node-exporter:9100" + + - job_name: "conjur" + static_configs: + - targets: + - "conjur:3000" diff --git a/dev/start b/dev/start index 5bc50c8932..ec91de8df9 100755 --- a/dev/start +++ b/dev/start @@ -508,6 +508,7 @@ init_metrics() { return fi env_args+=(-e "CONJUR_TELEMETRY_ENABLED=true") + services+=(prometheus node-exporter) } start_auth_services() { From 937a42c70fcd05bbcb5dfef6ee64b794c915a1bd Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Mon, 3 Oct 2022 13:32:54 -0600 Subject: [PATCH 175/665] Add telemetry docs (cherry picked from commit 1e0e66766a5c664388aa2e319363d7c47977e864) --- TELEMETRY.md | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 TELEMETRY.md diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000000..57b6c21298 --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,172 @@ +# Conjur Telemetry + +Conjur provides a configurable telemetry feature built on +[Prometheus](https://prometheus.io/), which is the preferred open source +monitoring tool for Cloud Native applications. When enabled, it will capture +performance and usage metrics of the running Conjur instance. These metrics are +exposed via a REST endpoint (/metrics) where Prometheus can scrape the data and +archive it as a queryable time series. This increases the observability of a +running Conjur instance and allows for easy integration with popular +visualization and monitoring tools. + +## Metrics + +This implementation leverages the following supported metric types via the +[Prometheus Ruby client library](https://github.com/prometheus/client_ruby): +| Type | Description +| --- | ----------- | +| counter | A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart. | +| gauge | A metric that represents a single numerical value that can arbitrarily go up and down. | +| histogram | A metric which samples observations (usually things like request durations or response sizes) and counts them in configurable buckets. | + +See the [Prometheus docs](https://prometheus.io/docs/concepts/metric_types/) for +more on supported metric types. + +### Defined Metrics + +The following metrics are provided with this implementation and will be captured +by default when telemetry is enabled: +| Metric | Type | Description | Labels\* | +| - | - | - | -| +| conjur_http_server_request_exceptions_total| counter | Total number of exceptions that have occured in Conjur during API requests. | operation, exception, message | +| conjur_http_server_requests_total | counter | Total number of API requests handled Conjur and resulting response codes. | operation, code | +| conjur_http_server_request_duration_seconds | histogram | Time series data of API request durations. | operation | +| conjur_server_authenticator | gauge | Number of authenticators installed, configured, and enabled. | type, status | +| conjur_resource_count | counter | Number of resources in the Conjur database. | kind | +| conjur_role_count | counter | Number of roles in the Conjur database. | kind | + +\*Labels are the identifiers by which metrics are logically grouped. For example +`conjur_http_server_requests_total` with the labels `operation` and `code` may +appear like so in the metrics registry: + +```txt +conjur_http_server_requests_total{code="200",operation="getAccessToken"} 1.0 +conjur_http_server_requests_total{code="201",operation="loadPolicy"} 1502.0 +conjur_http_server_requests_total{code="409",operation="loadPolicy"} 1498.0 +conjur_http_server_requests_total{code="401",operation="loadPolicy"} 327.0 +conjur_http_server_requests_total{code="200",operation="getMetrics"} 60.0 +conjur_http_server_requests_total{code="401",operation="unknown"} 62.0 +``` + +This registry format is consistent with the [data model for Prometheus +metrics](https://prometheus.io/docs/concepts/data_model/). + +## Configuration + +### Enabling Metrics Collection + +Metrics telemetry is off by default. It can be enabled in the following ways, +consistent with Conjur's usage of [Anyway Config](https://github.com/palkan/anyway_config): + +| **Name** | **Type** | **Default** | **Required?** | +|----------|----------|-------------|---------------| +| CONJUR_TELEMETRY_ENABLED | Env variable | None | No | +| telemetry_enabled | Key in Config file | None | No | + +Starting Conjur with either of the above configurations set to `true` will result +in initialization of the telemetry feature. + +### Metrics Storage + +Metrics are stored in the Prometheus client store, which is to say they are +stored on the volume of the container running Conjur. The default path for this +is `/tmp/prometheus` but a custom path can also be read in from the environment +variable `CONJUR_METRICS_DIR` on initialization. + +When Prometheus is running alongside Conjur, it can be configured to +periodically scrape metric values via the `/metrics` endpoint. It will keep a +time series of the configured metrics and store this data in a queryable +[on-disk database](https://prometheus.io/docs/prometheus/latest/storage/). See +[prometheus.yml](https://github.com/cyberark/conjur/dev/files/prometheus/prometheus.yml) +for a sample Prometheus config with Conjur as a scrape target. + +## Instrumenting New Metrics + +The following represents a high-level pattern which can be replicated to +instrument new Conjur metrics. Since the actual implementation will vary based +on the type of metric, how the pub/sub event should be instrumented, etc. it is +best to review the existing examples and determine the best approach on a +case-by-case basis. + +1. Create a metric class under the Monitoring::Metrics module (see +`/lib/monitoring/metrics` for examples) +1. Implement `setup(registry, pubsub)` method + 1. Initialize the metric by setting instance variables defining the metric + name, description, labels, etc. + 1. Expose the above instance variables via an attribute reader + 1. Register the metric by calling `Metrics.create_metric(self, :type)` where + type can be `counter`, `gauge`, or `histogram` +1. Implement `update` method to define update behavior + 1. Get the metric from the registry + 1. Determine the label values + 1. Determine and set the metric values +1. Implement a publishing event* + 1. Determine where in the code an event should be triggered which updates + the metric + 1. Use the PubSub singleton class to instrument the correct event i.e. + `Monitoring::PubSub.instance.publish('conjur.policy_loaded')` +1. Add the newly-defined metric to Prometheus initializer +(`/config/initializers/prometheus.rb`) + +\*Since instrumenting Pub/Sub events may involve modifying existing code, it +should be as unintrusive as possible. For example, the existing metrics use the +following two methods to avoid modifying any Conjur behavior or impacting +performance: + +* For HTTP requests - instrument the `conjur.request` from the middleware layer +so it does not require changes to Conjur code +* For Policy loading - instrument the `conjur.policy_loaded` event using an +`after_action` hook, which avoids modifying any controller methods + +## Security + +Prometheus supports either an unprotected `/metrics` endpoint, or [basic auth +via the scrape +config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config). +For the sake of reducing the burden on developers, it was elected to leave this +endpoint open by handling it in middleware, bypassing authentication +requirements. This was a conscious decision since Conjur already contains other +unprotected endpoints for debugging/status info. None of the metrics data +captured will contain sensitive values or data. + +It was also taken into account that production deployments of Conjur are less +likely to leverage this feature, but if they do, there will almost certainly be +a load balancer which can easily be configured to require basic auth on the +`/metrics` endpoint if required. + +## Integrations + +As mentioned, Prometheus allows for a variety of integrations for monitoring +captured metrics. [Grafana](https://prometheus.io/docs/visualization/grafana/) +provides a popular lightweight option for creating custom dashboards and +visualizing your data based on queries against Prometheus' data store. + +[AWS +Cloudwatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/ContainerInsights-Prometheus.html) +also offers a powerful option for aggregating metrics stored in Prometheus and +integrating them into its Container Insights platform in AWS +[ECS](https://aws-otel.github.io/docs/getting-started/container-insights/ecs-prometheus) +or +[EKS](https://aws-otel.github.io/docs/getting-started/container-insights/eks-prometheus) +environments. + +Similar options exist for other popular Kubernetes and cloud-monitoring +platforms, such as [Microsoft's Azure +Monitor](https://learn.microsoft.com/en-us/azure/azure-monitor/containers/container-insights-prometheus-integration) +and [Google's Cloud +Monitoring](https://cloud.google.com/stackdriver/docs/managed-prometheus). + +## Performance + +Benchmarks were taken with and without the Conjur telemetry feature enabled. It +was found that having telemetry enabled had only a negligible impact +(sub-millisecond) on system performance for handling most requests. + +By far the most expensive action is policy loading, which triggers an update to +HTTP request metrics as well as resource, role, and authenticator count metrics. +In this case, there was a 2-4% increase in processing time due to the metric +updates having to wait for a DB write to complete before being able to retrieve +the updated metric values. + +The full set of benchmarks can be reviewed +[here.](https://gist.github.com/gl-johnson/4b7fdb70a3b671f634731fe07615cedd) From 4a37c888963091450f8f722f904ec706d4b4f238 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Mon, 17 Oct 2022 15:03:32 -0600 Subject: [PATCH 176/665] Lazy setup of metrics, remove unused throttling code/tests (cherry picked from commit 336f1abb20927b4392973bb906a1e456fe64f5d2) --- config/initializers/prometheus.rb | 28 +++++++------ lib/monitoring/metrics.rb | 9 ----- .../metrics/api_exception_counter.rb | 2 +- lib/monitoring/metrics/api_request_counter.rb | 2 +- .../metrics/api_request_histogram.rb | 2 +- lib/monitoring/metrics/authenticator_gauge.rb | 5 +-- .../metrics/policy_resouce_gauge.rb | 5 +-- lib/monitoring/metrics/policy_role_gauge.rb | 7 ++-- .../middleware/prometheus_collector.rb | 12 ++++-- .../metrics/authenticator_metrics_spec.rb | 11 +----- .../monitoring/metrics/policy_metrics_spec.rb | 39 +++---------------- .../middleware/prometheus_collector_spec.rb | 8 +++- 12 files changed, 49 insertions(+), 81 deletions(-) diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index 3e803982bd..4986e1cf32 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -1,13 +1,11 @@ -require 'monitoring/pub_sub' +Rails.application.configure do + # The PubSub module needs to be loaded regardless of whether telemetry is + # enabled to prevent errors if/when the injected code executes + require 'monitoring/pub_sub' + return unless config.conjur_config.telemetry_enabled -if Rails.application.config.conjur_config.telemetry_enabled - require 'monitoring/prometheus' - require 'monitoring/metrics' - # Require all defined metrics - Dir.glob(Rails.root + 'lib/monitoring/metrics/*.rb', &method(:require)) - - # Load the authentication module early so that telemetry can see which authenticators are installed on startup - Dir.glob(Rails.root + 'app/domain/authentication/**/*.rb', &method(:require)) + # Require all defined metrics/modules + Dir.glob(Rails.root + 'lib/monitoring/**/*.rb', &method(:require)) # Register new metrics and setup the Prometheus client store metrics = [ @@ -18,11 +16,17 @@ Monitoring::Metrics::PolicyRoleGauge.new, Monitoring::Metrics::AuthenticatorGauge.new, ] - Monitoring::Prometheus.setup(metrics: metrics) + registry = ::Prometheus::Client::Registry.new + + # Use a callback to perform lazy setup on first incoming request + # - avoids race condition with DB initialization + lazy_init = lambda do + Monitoring::Prometheus.setup(metrics: metrics, registry: registry) + end # Initialize Prometheus middleware. We want to ensure that the middleware # which collects and exports metrics is loaded at the start of the # middleware chain to prevent any modifications to incoming HTTP requests - Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: Monitoring::Prometheus.registry, path: '/metrics') - Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusCollector, pubsub: Monitoring::PubSub.instance) + Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusExporter, registry: registry, path: '/metrics') + Rails.application.config.middleware.insert_before(0, Monitoring::Middleware::PrometheusCollector, pubsub: Monitoring::PubSub.instance, lazy_init: lazy_init) end diff --git a/lib/monitoring/metrics.rb b/lib/monitoring/metrics.rb index 99705fbaf3..68cc49ba62 100644 --- a/lib/monitoring/metrics.rb +++ b/lib/monitoring/metrics.rb @@ -54,16 +54,7 @@ def setup_subscriber(metric) metric.pubsub.subscribe(metric.sub_event_name) do |payload| metric.update(payload) end - throttle_policy_event(metric) unless !metric.throttle end - def throttle_policy_event(metric) - # TODO: Revisit throttling for metrics which execute DB queries. - # Currently this method is only used to group events that should run - # when a policy is loaded. It does not throttle the amount of updates. - metric.pubsub.subscribe('conjur.policy_loaded') do - metric.pubsub.publish(metric.sub_event_name) - end - end end end diff --git a/lib/monitoring/metrics/api_exception_counter.rb b/lib/monitoring/metrics/api_exception_counter.rb index 9d879ae9e8..f0cca45f49 100644 --- a/lib/monitoring/metrics/api_exception_counter.rb +++ b/lib/monitoring/metrics/api_exception_counter.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class ApiExceptionCounter - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name def setup(registry, pubsub) @registry = registry diff --git a/lib/monitoring/metrics/api_request_counter.rb b/lib/monitoring/metrics/api_request_counter.rb index 86b17e1d25..57d56c36c9 100644 --- a/lib/monitoring/metrics/api_request_counter.rb +++ b/lib/monitoring/metrics/api_request_counter.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class ApiRequestCounter - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name def setup(registry, pubsub) @registry = registry diff --git a/lib/monitoring/metrics/api_request_histogram.rb b/lib/monitoring/metrics/api_request_histogram.rb index 3ccf2e5839..7c9fa9fd55 100644 --- a/lib/monitoring/metrics/api_request_histogram.rb +++ b/lib/monitoring/metrics/api_request_histogram.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class ApiRequestHistogram - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name def setup(registry, pubsub) @registry = registry diff --git a/lib/monitoring/metrics/authenticator_gauge.rb b/lib/monitoring/metrics/authenticator_gauge.rb index 8d1a57459a..7971c7bc81 100644 --- a/lib/monitoring/metrics/authenticator_gauge.rb +++ b/lib/monitoring/metrics/authenticator_gauge.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class AuthenticatorGauge - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name def setup(registry, pubsub) @registry = registry @@ -9,8 +9,7 @@ def setup(registry, pubsub) @metric_name = :conjur_server_authenticator @docstring = 'Number of authenticators enabled' @labels = [:type, :status] - @sub_event_name = 'conjur.authenticator_count_update' - @throttle = true + @sub_event_name = 'conjur.policy_loaded' # Create/register the metric Metrics.create_metric(self, :gauge) diff --git a/lib/monitoring/metrics/policy_resouce_gauge.rb b/lib/monitoring/metrics/policy_resouce_gauge.rb index e492ecf5d7..7649efe587 100644 --- a/lib/monitoring/metrics/policy_resouce_gauge.rb +++ b/lib/monitoring/metrics/policy_resouce_gauge.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class PolicyResourceGauge - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name def setup(registry, pubsub) @registry = registry @@ -9,8 +9,7 @@ def setup(registry, pubsub) @metric_name = :conjur_resource_count @docstring = 'Number of resources in Conjur database' @labels = %i[kind] - @sub_event_name = 'conjur.resource_count_update' - @throttle = true + @sub_event_name = 'conjur.policy_loaded' # Create/register the metric Metrics.create_metric(self, :gauge) diff --git a/lib/monitoring/metrics/policy_role_gauge.rb b/lib/monitoring/metrics/policy_role_gauge.rb index 483747cbc0..e771ad15d7 100644 --- a/lib/monitoring/metrics/policy_role_gauge.rb +++ b/lib/monitoring/metrics/policy_role_gauge.rb @@ -1,7 +1,7 @@ module Monitoring module Metrics class PolicyRoleGauge - attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name, :throttle + attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name def setup(registry, pubsub) @registry = registry @@ -9,8 +9,7 @@ def setup(registry, pubsub) @metric_name = :conjur_role_count @docstring = 'Number of roles in Conjur database' @labels = %i[kind] - @sub_event_name = 'conjur.role_count_update' - @throttle = true + @sub_event_name = 'conjur.policy_loaded' # Create/register the metric Metrics.create_metric(self, :gauge) @@ -22,7 +21,7 @@ def setup(registry, pubsub) def update(*payload) metric = @registry.get(@metric_name) Monitoring::QueryHelper.instance.policy_role_counts.each do |kind, value| - metric.set(value, labels: { kind: kind }) + metric.set(value, labels: { kind: kind }) unless kind == '!' end end end diff --git a/lib/monitoring/middleware/prometheus_collector.rb b/lib/monitoring/middleware/prometheus_collector.rb index 34d77a6a05..1fce158069 100644 --- a/lib/monitoring/middleware/prometheus_collector.rb +++ b/lib/monitoring/middleware/prometheus_collector.rb @@ -9,9 +9,14 @@ class PrometheusCollector def initialize(app, options = {}) @app = app @pubsub = options[:pubsub] + @lazy_init = options[:lazy_init] end def call(env) # :nodoc: + unless @initialized + @initialized = true + @lazy_init.call + end trace(env) { @app.call(env) } end @@ -20,11 +25,11 @@ def call(env) # :nodoc: # Trace HTTP requests def trace(env) response = nil + operation = find_operation(env['REQUEST_METHOD'], env['PATH_INFO']) duration = Benchmark.realtime { response = yield } - record(env, response.first.to_s, duration) + record(env, response.first.to_s, duration, operation) return response rescue => exception - operation = find_operation(env['REQUEST_METHOD'], env['PATH_INFO']) @pubsub.publish( "conjur.request_exception", operation: operation, @@ -34,8 +39,7 @@ def trace(env) raise end - def record(env, code, duration) - operation = find_operation(env['REQUEST_METHOD'], env['PATH_INFO']) + def record(env, code, duration, operation) @pubsub.publish( "conjur.request", code: code, diff --git a/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb b/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb index 731b8e86ff..2643638d06 100644 --- a/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb +++ b/spec/lib/monitoring/metrics/authenticator_metrics_spec.rb @@ -1,15 +1,13 @@ require 'spec_helper' require 'monitoring/query_helper' require 'monitoring/metrics/authenticator_gauge' -Dir.glob(Rails.root + 'lib/monitoring/metrics/authenticator_.rb', &method(:require)) describe 'authenticator metrics', type: :request do before do - pubsub.unsubscribe('conjur.policy_loaded') - pubsub.unsubscribe('conjur.authenticator_count_update') - @authenticator_metric = Monitoring::Metrics::AuthenticatorGauge.new + pubsub.unsubscribe(@authenticator_metric.sub_event_name) + # Clear and setup the Prometheus client store Monitoring::Prometheus.setup( @@ -30,8 +28,6 @@ def headers_with_auth(payload) let(:pubsub) { Monitoring::PubSub.instance } - let(:policy_load_event_name) { 'conjur.policy_loaded' } - let(:policies_url) { '/policies/rspec/policy/root' } let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } @@ -46,21 +42,18 @@ def headers_with_auth(payload) context 'when a policy is loaded' do it 'publishes a policy load event (POST)' do - expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original expect(Monitoring::PubSub.instance).to receive(:publish).with(@authenticator_metric.sub_event_name) post(policies_url, env: headers_with_auth('[!variable test]')) end it 'publishes a policy load event (PUT)' do - expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original expect(Monitoring::PubSub.instance).to receive(:publish).with(@authenticator_metric.sub_event_name) put(policies_url, env: headers_with_auth('[!variable test]')) end it 'publishes a policy load event (PATCH)' do - expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original expect(Monitoring::PubSub.instance).to receive(:publish).with(@authenticator_metric.sub_event_name) patch(policies_url, env: headers_with_auth('[!variable test]')) diff --git a/spec/lib/monitoring/metrics/policy_metrics_spec.rb b/spec/lib/monitoring/metrics/policy_metrics_spec.rb index c25063ccea..c4553e76bd 100644 --- a/spec/lib/monitoring/metrics/policy_metrics_spec.rb +++ b/spec/lib/monitoring/metrics/policy_metrics_spec.rb @@ -5,12 +5,9 @@ describe 'policy metrics', type: :request do before do - pubsub.unsubscribe('conjur.policy_loaded') - pubsub.unsubscribe('conjur.resource_count_update') - pubsub.unsubscribe('conjur.role_count_update') - @resource_metric = Monitoring::Metrics::PolicyResourceGauge.new @role_metric = Monitoring::Metrics::PolicyRoleGauge.new + pubsub.unsubscribe(policy_load_event_name) # Clear and setup the Prometheus client store Monitoring::Prometheus.setup( @@ -27,10 +24,10 @@ def headers_with_auth(payload) let(:registry) { Monitoring::Prometheus.registry } - let(:metrics) { [ @resource_metric, @role_metric ] } - let(:pubsub) { Monitoring::PubSub.instance } + let(:metrics) { [ @resource_metric, @role_metric ] } + let(:policy_load_event_name) { 'conjur.policy_loaded' } let(:policies_url) { '/policies/rspec/policy/root' } @@ -47,28 +44,19 @@ def headers_with_auth(payload) context 'when a policy is loaded' do it 'publishes a policy load event (POST)' do - expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original - - expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) - expect(Monitoring::PubSub.instance).to receive(:publish).with(@role_metric.sub_event_name) + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).once post(policies_url, env: headers_with_auth('[!variable test]')) end it 'publishes a policy load event (PUT)' do - expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original - - expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) - expect(Monitoring::PubSub.instance).to receive(:publish).with(@role_metric.sub_event_name) + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).once put(policies_url, env: headers_with_auth('[!variable test]')) end it 'publishes a policy load event (PATCH)' do - expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).and_call_original - - expect(Monitoring::PubSub.instance).to receive(:publish).with(@resource_metric.sub_event_name) - expect(Monitoring::PubSub.instance).to receive(:publish).with(@role_metric.sub_event_name) + expect(Monitoring::PubSub.instance).to receive(:publish).with(policy_load_event_name).once patch(policies_url, env: headers_with_auth('[!variable test]')) end @@ -92,20 +80,5 @@ def headers_with_auth(payload) expect(resources_after - resources_before).to eql(1.0) expect(roles_after - roles_before).to eql(1.0) end - - end - - context 'when multiple policies are loaded' do - - # Revisit this test when update throttling has been implemented - xit 'throttles policy events' do - expect(@resource_metric).to receive(:update).at_most(2).times - post(policies_url, env: headers_with_auth('[!variable test1]')) - post(policies_url, env: headers_with_auth('[!variable test2]')) - post(policies_url, env: headers_with_auth('[!variable test3]')) - post(policies_url, env: headers_with_auth('[!variable test4]')) - post(policies_url, env: headers_with_auth('[!variable test5]')) - end - end end diff --git a/spec/lib/monitoring/middleware/prometheus_collector_spec.rb b/spec/lib/monitoring/middleware/prometheus_collector_spec.rb index 6b99699c58..9d05d066e5 100644 --- a/spec/lib/monitoring/middleware/prometheus_collector_spec.rb +++ b/spec/lib/monitoring/middleware/prometheus_collector_spec.rb @@ -28,13 +28,19 @@ let(:env) { Rack::MockRequest.env_for } + let(:lazy_init) { + lambda do + # nothing + end + } + let(:app) do app = ->(env) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } end let(:pubsub) { Monitoring::PubSub.instance } - let(:options) { { pubsub: pubsub } } + let(:options) { { pubsub: pubsub, lazy_init: lazy_init} } subject { described_class.new(app, **options) } From 6cc35af904dc0ad072b35318ee48d59c31054767 Mon Sep 17 00:00:00 2001 From: Shlomo Heigh Date: Tue, 18 Jul 2023 13:25:45 -0400 Subject: [PATCH 177/665] Log monitoring exceptions (cherry picked from commit d1d44a1a982a3f096b55b91b5d7f358fb1a8956b) --- app/domain/logs.rb | 7 +++++++ lib/monitoring/metrics/api_exception_counter.rb | 2 +- lib/monitoring/middleware/prometheus_collector.rb | 6 ++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/domain/logs.rb b/app/domain/logs.rb index 4c72d8db1a..e3a8126ecf 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -936,4 +936,11 @@ module Config code: "CONJ00150W" ) end + + module Monitoring + ExceptionDuringRequestRecording = ::Util::TrackableLogMessageClass.new( + msg: "Exception during request recording: {0-exception}", + code: "CONJ00151D" + ) + end end diff --git a/lib/monitoring/metrics/api_exception_counter.rb b/lib/monitoring/metrics/api_exception_counter.rb index f0cca45f49..003895f98f 100644 --- a/lib/monitoring/metrics/api_exception_counter.rb +++ b/lib/monitoring/metrics/api_exception_counter.rb @@ -16,7 +16,7 @@ def setup(registry, pubsub) end def update(payload) - metric = @registry.get(@metric_name) + metric = @registry.get(metric_name) update_labels = { operation: payload[:operation], exception: payload[:exception], diff --git a/lib/monitoring/middleware/prometheus_collector.rb b/lib/monitoring/middleware/prometheus_collector.rb index 1fce158069..03167399b0 100644 --- a/lib/monitoring/middleware/prometheus_collector.rb +++ b/lib/monitoring/middleware/prometheus_collector.rb @@ -46,9 +46,8 @@ def record(env, code, duration, operation) operation: operation, duration: duration ) - rescue - # TODO: log unexpected exception during request recording - nil + rescue => e + @logger.debug(LogMessages::Monitoring::ExceptionDuringRequestRecording.new(e.inspect)) end def find_operation(method, path) @@ -57,7 +56,6 @@ def find_operation(method, path) return op[:operation] end end - return "unknown" end end end From ebaa848cd1a243cf21e5cea8b5d1d1b98cca907b Mon Sep 17 00:00:00 2001 From: Shlomo Heigh Date: Tue, 18 Jul 2023 14:58:14 -0400 Subject: [PATCH 178/665] Use custom error (cherry picked from commit b9df4e86596fae9f573f1bcf629b2585beae96f3) --- app/domain/errors.rb | 8 ++++++++ lib/monitoring/metrics.rb | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 2a9c59c5a3..6593562702 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -748,4 +748,12 @@ module Util code: "CONJ00044E" ) end + + module Monitoring + + InvalidOrMissingMetricType = ::Util::TrackableErrorClass.new( + msg: "Invalid or missing metric type: {0-metric-type}", + code: "CONJ00152E" + ) + end end diff --git a/lib/monitoring/metrics.rb b/lib/monitoring/metrics.rb index 68cc49ba62..35c3123791 100644 --- a/lib/monitoring/metrics.rb +++ b/lib/monitoring/metrics.rb @@ -11,7 +11,7 @@ def create_metric(metric, type) when :histogram create_histogram_metric(metric) else - raise Exception.new "Invalid or missing metric type." + raise Errors::Monitoring::InvalidOrMissingMetricType.new(type.to_s) end end From fb0437ba20aa2abd6ce59dd8e8dd6d937d206551 Mon Sep 17 00:00:00 2001 From: Shlomo Heigh Date: Tue, 18 Jul 2023 15:31:59 -0400 Subject: [PATCH 179/665] Use dependency injection for installed authenticators (cherry picked from commit 36ce711be015a4e6b21ec5c46c6a24b4adf41463) --- TELEMETRY.md | 14 ++-- lib/conjur/conjur_config.rb | 2 +- lib/monitoring/metrics.rb | 2 +- .../metrics/api_exception_counter.rb | 2 +- lib/monitoring/metrics/api_request_counter.rb | 2 +- .../metrics/api_request_histogram.rb | 2 +- lib/monitoring/metrics/authenticator_gauge.rb | 37 ++++---- .../metrics/policy_resouce_gauge.rb | 4 +- lib/monitoring/metrics/policy_role_gauge.rb | 4 +- .../middleware/prometheus_collector.rb | 13 +-- lib/monitoring/operations.rb | 84 +++++++++---------- lib/monitoring/pub_sub.rb | 2 +- 12 files changed, 86 insertions(+), 82 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index 57b6c21298..08dbbf9366 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -89,26 +89,26 @@ best to review the existing examples and determine the best approach on a case-by-case basis. 1. Create a metric class under the Monitoring::Metrics module (see -`/lib/monitoring/metrics` for examples) + `/lib/monitoring/metrics` for examples) 1. Implement `setup(registry, pubsub)` method 1. Initialize the metric by setting instance variables defining the metric - name, description, labels, etc. + name, description, labels, etc. 1. Expose the above instance variables via an attribute reader 1. Register the metric by calling `Metrics.create_metric(self, :type)` where - type can be `counter`, `gauge`, or `histogram` + type can be `counter`, `gauge`, or `histogram` 1. Implement `update` method to define update behavior 1. Get the metric from the registry 1. Determine the label values 1. Determine and set the metric values 1. Implement a publishing event* 1. Determine where in the code an event should be triggered which updates - the metric + the metric 1. Use the PubSub singleton class to instrument the correct event i.e. - `Monitoring::PubSub.instance.publish('conjur.policy_loaded')` + `Monitoring::PubSub.instance.publish('conjur.policy_loaded')` 1. Add the newly-defined metric to Prometheus initializer -(`/config/initializers/prometheus.rb`) + (`/config/initializers/prometheus.rb`) -\*Since instrumenting Pub/Sub events may involve modifying existing code, it +*Since instrumenting Pub/Sub events may involve modifying existing code, it should be as unintrusive as possible. For example, the existing metrics use the following two methods to avoid modifying any Conjur behavior or impacting performance: diff --git a/lib/conjur/conjur_config.rb b/lib/conjur/conjur_config.rb index fb776a8bac..5a8c29c823 100644 --- a/lib/conjur/conjur_config.rb +++ b/lib/conjur/conjur_config.rb @@ -274,7 +274,7 @@ def authenticators_valid? end def telemetry_enabled_valid? - [true, false].include? telemetry_enabled + [true, false].include?(telemetry_enabled) end end end diff --git a/lib/monitoring/metrics.rb b/lib/monitoring/metrics.rb index 35c3123791..ede73fc5aa 100644 --- a/lib/monitoring/metrics.rb +++ b/lib/monitoring/metrics.rb @@ -11,7 +11,7 @@ def create_metric(metric, type) when :histogram create_histogram_metric(metric) else - raise Errors::Monitoring::InvalidOrMissingMetricType.new(type.to_s) + raise Errors::Monitoring::InvalidOrMissingMetricType, type.to_s end end diff --git a/lib/monitoring/metrics/api_exception_counter.rb b/lib/monitoring/metrics/api_exception_counter.rb index 003895f98f..616512384f 100644 --- a/lib/monitoring/metrics/api_exception_counter.rb +++ b/lib/monitoring/metrics/api_exception_counter.rb @@ -16,7 +16,7 @@ def setup(registry, pubsub) end def update(payload) - metric = @registry.get(metric_name) + metric = registry.get(metric_name) update_labels = { operation: payload[:operation], exception: payload[:exception], diff --git a/lib/monitoring/metrics/api_request_counter.rb b/lib/monitoring/metrics/api_request_counter.rb index 57d56c36c9..cd27e00d89 100644 --- a/lib/monitoring/metrics/api_request_counter.rb +++ b/lib/monitoring/metrics/api_request_counter.rb @@ -16,7 +16,7 @@ def setup(registry, pubsub) end def update(payload) - metric = @registry.get(@metric_name) + metric = registry.get(metric_name) update_labels = { code: payload[:code], operation: payload[:operation] diff --git a/lib/monitoring/metrics/api_request_histogram.rb b/lib/monitoring/metrics/api_request_histogram.rb index 7c9fa9fd55..0d7fa2d59b 100644 --- a/lib/monitoring/metrics/api_request_histogram.rb +++ b/lib/monitoring/metrics/api_request_histogram.rb @@ -16,7 +16,7 @@ def setup(registry, pubsub) end def update(payload) - metric = @registry.get(@metric_name) + metric = registry.get(metric_name) update_labels = { operation: payload[:operation] } diff --git a/lib/monitoring/metrics/authenticator_gauge.rb b/lib/monitoring/metrics/authenticator_gauge.rb index 7971c7bc81..66bb1891ee 100644 --- a/lib/monitoring/metrics/authenticator_gauge.rb +++ b/lib/monitoring/metrics/authenticator_gauge.rb @@ -3,13 +3,18 @@ module Metrics class AuthenticatorGauge attr_reader :registry, :pubsub, :metric_name, :docstring, :labels, :sub_event_name - def setup(registry, pubsub) - @registry = registry - @pubsub = pubsub + def initialize(installed_authenticators: Authentication::InstalledAuthenticators) @metric_name = :conjur_server_authenticator @docstring = 'Number of authenticators enabled' - @labels = [:type, :status] + @labels = %i[type status] @sub_event_name = 'conjur.policy_loaded' + + @installed_authenticators = installed_authenticators + end + + def setup(registry, pubsub) + @registry = registry + @pubsub = pubsub # Create/register the metric Metrics.create_metric(self, :gauge) @@ -18,43 +23,41 @@ def setup(registry, pubsub) update end - def update(*payload) - metric = @registry.get(@metric_name) + def update(*_payload) + metric = registry.get(metric_name) update_enabled_authenticators(metric) update_installed_authenticators(metric) update_configured_authenticators(metric) end def update_enabled_authenticators(metric) - enabled_authenticators = Authentication::InstalledAuthenticators.enabled_authenticators + enabled_authenticators = @installed_authenticators.enabled_authenticators enabled_authenticator_counts = get_authenticator_counts(enabled_authenticators) enabled_authenticator_counts.each do |type, count| - metric.set(count, labels: { type: type, status: 'enabled'}) + metric.set(count, labels: { type: type, status: 'enabled' }) end end def update_installed_authenticators(metric) - installed_authenticators = Authentication::InstalledAuthenticators.authenticators(ENV).keys + installed_authenticators = @installed_authenticators.authenticators(ENV).keys installed_authenticators.each do |type| - metric.set(1, labels: { type: type, status: 'installed'}) + metric.set(1, labels: { type: type, status: 'installed' }) end end def update_configured_authenticators(metric) - configured_authenticators = Authentication::InstalledAuthenticators.configured_authenticators + configured_authenticators = @installed_authenticators.configured_authenticators configured_authenticator_counts = get_authenticator_counts(configured_authenticators) configured_authenticator_counts.each do |type, count| - metric.set(count, labels: { type: type, status: 'configured'}) + metric.set(count, labels: { type: type, status: 'configured' }) end end def get_authenticator_counts(authenticators) - authenticator_counts = {} - authenticators.each do |authenticator| - type = authenticator.split('/')[0] - authenticator_counts[type] ? authenticator_counts[type] += 1 : authenticator_counts[type] = 1 + authenticators.each_with_object(Hash.new(0)) do |authenticator, rtn| + type = authenticator.split('/').first + rtn[type] += 1 end - return authenticator_counts end end end diff --git a/lib/monitoring/metrics/policy_resouce_gauge.rb b/lib/monitoring/metrics/policy_resouce_gauge.rb index 7649efe587..b7a1d1a061 100644 --- a/lib/monitoring/metrics/policy_resouce_gauge.rb +++ b/lib/monitoring/metrics/policy_resouce_gauge.rb @@ -18,8 +18,8 @@ def setup(registry, pubsub) update end - def update(*payload) - metric = @registry.get(@metric_name) + def update(*_payload) + metric = registry.get(metric_name) Monitoring::QueryHelper.instance.policy_resource_counts.each do |kind, value| metric.set(value, labels: { kind: kind }) end diff --git a/lib/monitoring/metrics/policy_role_gauge.rb b/lib/monitoring/metrics/policy_role_gauge.rb index e771ad15d7..540b7bfa48 100644 --- a/lib/monitoring/metrics/policy_role_gauge.rb +++ b/lib/monitoring/metrics/policy_role_gauge.rb @@ -18,8 +18,8 @@ def setup(registry, pubsub) update end - def update(*payload) - metric = @registry.get(@metric_name) + def update(*_payload) + metric = registry.get(metric_name) Monitoring::QueryHelper.instance.policy_role_counts.each do |kind, value| metric.set(value, labels: { kind: kind }) unless kind == '!' end diff --git a/lib/monitoring/middleware/prometheus_collector.rb b/lib/monitoring/middleware/prometheus_collector.rb index 03167399b0..a58d9892f6 100644 --- a/lib/monitoring/middleware/prometheus_collector.rb +++ b/lib/monitoring/middleware/prometheus_collector.rb @@ -1,5 +1,5 @@ require 'benchmark' -require_relative '../operations.rb' +require_relative '../operations' module Monitoring module Middleware @@ -28,18 +28,18 @@ def trace(env) operation = find_operation(env['REQUEST_METHOD'], env['PATH_INFO']) duration = Benchmark.realtime { response = yield } record(env, response.first.to_s, duration, operation) - return response - rescue => exception + response + rescue => e @pubsub.publish( "conjur.request_exception", operation: operation, - exception: exception.class.name, - message: exception + exception: e.class.name, + message: e ) raise end - def record(env, code, duration, operation) + def record(_env, code, duration, operation) @pubsub.publish( "conjur.request", code: code, @@ -56,6 +56,7 @@ def find_operation(method, path) return op[:operation] end end + "unknown" end end end diff --git a/lib/monitoring/operations.rb b/lib/monitoring/operations.rb index 98e9c3240a..55c8fe39d6 100644 --- a/lib/monitoring/operations.rb +++ b/lib/monitoring/operations.rb @@ -4,224 +4,224 @@ module Metrics # AccountsApi (undocumented) { method: "POST", - pattern: /^(\/accounts)$/, + pattern: %r{^(/accounts)$}, operation: "createAccount" }, { method: "GET", - pattern: /^(\/accounts)$/, + pattern: %r{^(/accounts)$}, operation: "getAccounts" }, { method: "DELETE", - pattern: /^(\/accounts)(\/[^\/]+)$/, + pattern: %r{^(/accounts)(/[^/]+)$}, operation: "deleteAccount" }, # AuthenticationApi { method: "PUT", - pattern: /^(\/authn)(\/[^\/]+)(\/password)$/, + pattern: %r{^(/authn)(/[^/]+)(/password)$}, operation: "changePassword" }, { method: "PATCH", - pattern: /^(\/authn-)([^\/]+)(\/[^\/]+){2,3}$/, + pattern: %r{^(/authn-)([^/]+)(/[^/]+){2,3}$}, operation: "enableAuthenticatorInstance" }, { method: "GET", - pattern: /^(\/authn)(\/[^\/]+)(\/login)$/, + pattern: %r{^(/authn)(/[^/]+)(/login)$}, operation: "getAPIKey" }, { method: "GET", - pattern: /^(\/authn-ldap)(\/[^\/]+){2}(\/login)$/, + pattern: %r{^(/authn-ldap)(/[^/]+){2}(/login)$}, operation: "getAPIKeyViaLDAP" }, { method: "POST", - pattern: /^(\/authn)(\/[^\/]+){2}(\/authenticate)$/, + pattern: %r{^(/authn)(/[^/]+){2}(/authenticate)$}, operation: "getAccessToken" }, { method: "POST", - pattern: /^(\/authn-iam)(\/[^\/]+){3}(\/authenticate)$/, + pattern: %r{^(/authn-iam)(/[^/]+){3}(/authenticate)$}, operation: "getAccessTokenViaAWS" }, { method: "POST", - pattern: /^(\/authn-azure)(\/[^\/]+){3}(\/authenticate)$/, + pattern: %r{^(/authn-azure)(/[^/]+){3}(/authenticate)$}, operation: "getAccessTokenViaAzure" }, { method: "POST", - pattern: /^(\/authn-gcp)(\/[^\/]+)(\/authenticate)$/, + pattern: %r{^(/authn-gcp)(/[^/]+)(/authenticate)$}, operation: "getAccessTokenViaGCP" }, { method: "POST", - pattern: /^(\/authn-k8s)(\/[^\/]+){3}(\/authenticate)$/, + pattern: %r{^(/authn-k8s)(/[^/]+){3}(/authenticate)$}, operation: "getAccessTokenViaKubernetes" }, { method: "POST", - pattern: /^(\/authn-ldap)(\/[^\/]+){3}(\/authenticate)$/, + pattern: %r{^(/authn-ldap)(/[^/]+){3}(/authenticate)$}, operation: "getAccessTokenViaLDAP" }, { method: "POST", - pattern: /^(\/authn-oidc)(\/[^\/]+){2}(\/authenticate)$/, + pattern: %r{^(/authn-oidc)(/[^/]+){2}(/authenticate)$}, operation: "getAccessTokenViaOIDC" }, { method: "POST", - pattern: /^(\/authn-jwt)(\/[^\/]+){2,3}(\/authenticate)$/, + pattern: %r{^(/authn-jwt)(/[^/]+){2,3}(/authenticate)$}, operation: "getAccessTokenViaJWT" }, { method: "POST", - pattern: /^(\/authn-k8s)(\/[^\/]+)(\/inject_client_cert)$/, + pattern: %r{^(/authn-k8s)(/[^/]+)(/inject_client_cert)$}, operation: "k8sInjectClientCert" }, { method: "PUT", - pattern: /^(\/authn)(\/[^\/]+)(\/api_key)$/, + pattern: %r{^(/authn)(/[^/]+)(/api_key)$}, operation: "rotateAPIKey" }, # CertificateAuthorityApi { method: "POST", - pattern: /^(\/ca)(\/[^\/]+){2}(\/sign)$/, + pattern: %r{^(/ca)(/[^/]+){2}(/sign)$}, operation: "sign" }, # HostFactoryApi { method: "POST", - pattern: /^(\/host_factories\/hosts)$/, + pattern: %r{^(/host_factories/hosts)$}, operation: "createHost" }, { method: "POST", - pattern: /^(\/host_factory_tokens)$/, + pattern: %r{^(/host_factory_tokens)$}, operation: "createToken" }, { method: "DELETE", - pattern: /^(\/host_factory_tokens)(\/[^\/]+)$/, + pattern: %r{^(/host_factory_tokens)(/[^/]+)$}, operation: "revokeToken" }, # MetricsApi { method: "GET", - pattern: /^(\/metrics)$/, + pattern: %r{^(/metrics)$}, operation: "getMetrics" }, # PoliciesApi { method: "POST", - pattern: /^(\/policies)(\/[^\/]+){2,3}(\/.*)$/, + pattern: %r{^(/policies)(/[^/]+){2,3}(/.*)$}, operation: "loadPolicy" }, { method: "PUT", - pattern: /^(\/policies)(\/[^\/]+){2,3}(\/.*)$/, + pattern: %r{^(/policies)(/[^/]+){2,3}(/.*)$}, operation: "replacePolicy" }, { method: "PATCH", - pattern: /^(\/policies)(\/[^\/]+){2,3}(\/.*)$/, + pattern: %r{^(/policies)(/[^/]+){2,3}(/.*)$}, operation: "updatePolicy" }, # PublicKeysApi { method: "GET", - pattern: /^(\/public_keys)(\/[^\/]+){3}$/, + pattern: %r{^(/public_keys)(/[^/]+){3}$}, operation: "showPublicKeys" }, # ResourcesApi { method: "GET", - pattern: /^(\/resources)(\/[^\/]+){3}(\/.*)$/, + pattern: %r{^(/resources)(/[^/]+){3}(/.*)$}, operation: "showResource" }, { method: "GET", - pattern: /^(\/resources)(\/[^\/]+){1}$/, + pattern: %r{^(/resources)(/[^/]+){1}$}, operation: "showResourcesForAccount" }, { method: "GET", - pattern: /^(\/resources$)/, + pattern: %r{^(/resources$)}, operation: "showResourcesForAllAccounts" }, { method: "GET", - pattern: /^(\/resources)(\/[^\/]+){2}$/, + pattern: %r{^(/resources)(/[^/]+){2}$}, operation: "showResourcesForKind" }, # RolesApi { method: "POST", - pattern: /^(\/roles)(\/[^\/]+){3}$/, + pattern: %r{^(/roles)(/[^/]+){3}$}, operation: "addMemberToRole" }, { method: "DELETE", - pattern: /^(\/roles)(\/[^\/]+){3}$/, + pattern: %r{^(/roles)(/[^/]+){3}$}, operation: "removeMemberFromRole" }, { method: "GET", - pattern: /^(\/roles)(\/[^\/]+){3}$/, + pattern: %r{^(/roles)(/[^/]+){3}$}, operation: "showRole" }, # SecretsApi { method: "POST", - pattern: /^(\/secrets)(\/[^\/]+){2}(\/.*)$/, + pattern: %r{^(/secrets)(/[^/]+){2}(/.*)$}, operation: "createSecret" }, { method: "GET", - pattern: /^(\/secrets)(\/[^\/]+){3}$/, + pattern: %r{^(/secrets)(/[^/]+){3}$}, operation: "getSecret" }, { method: "GET", - pattern: /^(\/secrets)$/, + pattern: %r{^(/secrets)$}, operation: "getSecrets" }, # StatusApi { method: "GET", - pattern: /^(\/authenticators)$/, + pattern: %r{^(/authenticators)$}, operation: "getAuthenticators" }, { method: "GET", - pattern: /^(\/authn-gcp)(\/[^\/]+)(\/status)$/, + pattern: %r{^(/authn-gcp)(/[^/]+)(/status)$}, operation: "getGCPAuthenticatorStatus" }, { method: "GET", - pattern: /^(\/authn-)([^\/]+)(\/[^\/]+){2}(\/status)$/, + pattern: %r{^(/authn-)([^/]+)(/[^/]+){2}(/status)$}, operation: "getServiceAuthenticatorStatus" }, { method: "GET", - pattern: /^(\/whoami)$/, + pattern: %r{^(/whoami)$}, operation: "whoAmI" - }, - ] + } + ].freeze end end diff --git a/lib/monitoring/pub_sub.rb b/lib/monitoring/pub_sub.rb index 66c7eeb1e0..6745f0d1a7 100644 --- a/lib/monitoring/pub_sub.rb +++ b/lib/monitoring/pub_sub.rb @@ -13,7 +13,7 @@ def publish(name, payload = {}) def subscribe(name) ActiveSupport::Notifications.subscribe(name) do |_, _, _, _, payload| - yield payload + yield(payload) end end From a42b566dfdce7c8b653bc6a44c755c421469fdfc Mon Sep 17 00:00:00 2001 From: Kumbirai Tanekha Date: Wed, 26 Jul 2023 21:54:09 +0300 Subject: [PATCH 180/665] Add telemetry entry to CHANGELOG.md (cherry picked from commit ae920bd1bc296d06eb536db5d62983bb452c07e4) --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e04970c10..40523744e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,13 +136,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Remove auto-release options to allow for a pseudo-fork development on a branch -## [1.19.6] - 2023-07-05 +## [1.20.0] - 2023-07-11 + +### Added +- Telemetry support + [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) -## [1.19.5] - 2023-05-29 +## [1.19.5] - 2023-06-29 ### Security - Update bundler to 2.2.33 to remove CVE-2021-43809 @@ -195,7 +199,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [cyberark/conjur#2739](https://github.com/cyberark/conjur/pull/2739) - Upgraded rack to v2.2.6.4 to resolve CVE-2023-27539 [cyberark/conjur#2750](https://github.com/cyberark/conjur/pull/2750) -- Updated nokogiri to 1.14.3 for CVE-2023-29469 and CVE-2023-28484 and rails to +- Updated nokogiri to 1.14.3 for CVE-2023-29469 and CVE-2023-28484 and rails to 6.1.7.3 for CVE-2023-28120 in Gemfile.lock, nokogiri to 1.1.4.3 for CVE-2023-29469 and commonmarker to 0.23.9 for CVE-2023-24824 and CVE-2023-26485 in docs/Gemfile.lock (all Medium severity issues flagged by Dependabot) From 82fa0d4e61af5dffcf84003c85e449a2d59df5c4 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Tue, 25 Jul 2023 15:30:43 -0600 Subject: [PATCH 181/665] Add/refactor some unit tests (cherry picked from commit 1fb0fad0e555ea4bff5fddd47bd2139f1fce6bac) --- lib/monitoring/prometheus.rb | 6 +- spec/lib/monitoring/metrics_spec.rb | 98 ++++++------------- .../middleware/prometheus_collector_spec.rb | 4 +- .../middleware/prometheus_exporter_spec.rb | 16 +-- spec/lib/monitoring/prometheus_spec.rb | 75 ++++++++++++++ spec/lib/monitoring/query_helper_spec.rb | 57 ++++++----- 6 files changed, 151 insertions(+), 105 deletions(-) create mode 100644 spec/lib/monitoring/prometheus_spec.rb diff --git a/lib/monitoring/prometheus.rb b/lib/monitoring/prometheus.rb index 0630113f08..aa5daf1dd6 100644 --- a/lib/monitoring/prometheus.rb +++ b/lib/monitoring/prometheus.rb @@ -6,6 +6,8 @@ module Monitoring module Prometheus extend self + attr_reader :registry + def setup(options = {}) @registry = options[:registry] || ::Prometheus::Client::Registry.new @metrics_dir_path = ENV['CONJUR_METRICS_DIR'] || '/tmp/prometheus' @@ -21,10 +23,6 @@ def setup(options = {}) setup_metrics end - def registry - @registry - end - protected def clear_data_store diff --git a/spec/lib/monitoring/metrics_spec.rb b/spec/lib/monitoring/metrics_spec.rb index 26525ba571..0eabf114c2 100644 --- a/spec/lib/monitoring/metrics_spec.rb +++ b/spec/lib/monitoring/metrics_spec.rb @@ -1,75 +1,39 @@ -require 'prometheus/client/formats/text' -require 'monitoring/prometheus' +require 'monitoring/metrics' +require 'prometheus/client' -class SampleMetric - def setup(registry, pubsub) - registry.register(::Prometheus::Client::Gauge.new( - :test_gauge, - docstring: '...', - labels: [:test_label] - )) - - pubsub.subscribe("sample_test_gauge") do |payload| - metric = registry.get(:test_gauge) - metric.set(payload[:value], labels: payload[:labels]) - end +RSpec.describe Monitoring::Metrics do + class MockMetric + # does nothing end -end - -describe Monitoring::Prometheus do - let(:registry) { - Monitoring::Prometheus.setup - Monitoring::Prometheus.registry - } - - it 'creates a valid registry and allows metrics' do - gauge = registry.gauge(:foo, docstring: '...', labels: [:bar]) - gauge.set(21.534, labels: { bar: 'test' }) - expect(gauge.get(labels: { bar: 'test' })).to eql(21.534) - end - - it 'can use Pub/Sub events to update metrics on the registry' do - gauge = registry.gauge(:foo, docstring: '...', labels: [:bar]) - - pub_sub = Monitoring::PubSub.instance - pub_sub.subscribe("foo_event_name") do |payload| - labels = { - bar: payload[:bar] - } - gauge.set(payload[:value], labels: labels) + describe '#create_metric' do + context 'with valid metric type' do + it 'creates a gauge metric' do + expect(Monitoring::Metrics).to receive(:create_gauge_metric).with(MockMetric) + Monitoring::Metrics.create_metric(MockMetric, :gauge) + end + + it 'creates a counter metric' do + expect(Monitoring::Metrics).to receive(:create_counter_metric).with(MockMetric) + Monitoring::Metrics.create_metric(MockMetric, :counter) + end + + it 'creates a histogram metric' do + expect(Monitoring::Metrics).to receive(:create_histogram_metric).with(MockMetric) + Monitoring::Metrics.create_metric(MockMetric, :histogram) + end + + it 'creates a histogram metric (string)' do + expect(Monitoring::Metrics).to receive(:create_histogram_metric).with(MockMetric) + Monitoring::Metrics.create_metric(MockMetric, 'histogram') + end end - pub_sub.publish("foo_event_name", value: 100, bar: "omicron") - expect(gauge.get(labels: { bar: "omicron" })).to eql(100.0) - end - - context 'when given a list of metrics to setup' do - before do - @metric_obj = SampleMetric.new - @registry = ::Prometheus::Client::Registry.new - @mock_pubsub = double("Mock Monitoring::PubSub") - end - - def prometheus_setup - Monitoring::Prometheus.setup( - registry: @registry, - metrics: [ @metric_obj ], - pubsub: @mock_pubsub - ) - end - - it 'calls .setup for the metric class' do - expect(@metric_obj).to receive(:setup).with(@registry, @mock_pubsub) - prometheus_setup - end - - it 'adds custom metric definitions to the global registry and subscribes to related Pub/Sub events' do - expect(@mock_pubsub).to receive(:subscribe).with("sample_test_gauge") - prometheus_setup - - sample_metric = @registry.get(:test_gauge) - expect(sample_metric).not_to be_nil + context 'with invalid metric type' do + it 'raises an error' do + expect { Monitoring::Metrics.create_metric(MockMetric, :invalid_type) } + .to raise_error(Errors::Monitoring::InvalidOrMissingMetricType) + end end end end diff --git a/spec/lib/monitoring/middleware/prometheus_collector_spec.rb b/spec/lib/monitoring/middleware/prometheus_collector_spec.rb index 9d05d066e5..471096e890 100644 --- a/spec/lib/monitoring/middleware/prometheus_collector_spec.rb +++ b/spec/lib/monitoring/middleware/prometheus_collector_spec.rb @@ -46,10 +46,10 @@ it 'returns the app response' do env['PATH_INFO'] = "/foo" - status, _headers, _response = subject.call(env) + status, _headers, response = subject.call(env) expect(status).to eql(200) - expect(_response.first).to eql('OK') + expect(response.first).to eql('OK') end it 'traces request information' do diff --git a/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb index 22a8c5cbca..cfddf88009 100644 --- a/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb +++ b/spec/lib/monitoring/middleware/prometheus_exporter_spec.rb @@ -27,10 +27,10 @@ context 'when requesting app endpoints' do it 'returns the app response' do env['PATH_INFO'] = "/foo" - status, _headers, _response = subject.call(env) + status, _headers, response = subject.call(env) expect(status).to eql(200) - expect(_response.first).to eql('OK') + expect(response.first).to eql('OK') end end @@ -44,11 +44,11 @@ env['PATH_INFO'] = path env['HTTP_ACCEPT'] = headers.values[0] if headers.values[0] - status, _headers, _response = subject.call(env) + status, headers, response = subject.call(env) expect(status).to eql(200) - expect(_headers['Content-Type']).to eql(fmt::CONTENT_TYPE) - expect(_response.first).to eql(fmt.marshal(registry)) + expect(headers['Content-Type']).to eql(fmt::CONTENT_TYPE) + expect(response.first).to eql(fmt.marshal(registry)) end end @@ -60,11 +60,11 @@ env['PATH_INFO'] = path env['HTTP_ACCEPT'] = headers.values[0] if headers.values[0] - status, _headers, _response = subject.call(env) + status, headers, response = subject.call(env) expect(status).to eql(406) - expect(_headers['Content-Type']).to eql('text/plain') - expect(_response.first).to eql(message) + expect(headers['Content-Type']).to eql('text/plain') + expect(response.first).to eql(message) end end diff --git a/spec/lib/monitoring/prometheus_spec.rb b/spec/lib/monitoring/prometheus_spec.rb new file mode 100644 index 0000000000..26525ba571 --- /dev/null +++ b/spec/lib/monitoring/prometheus_spec.rb @@ -0,0 +1,75 @@ +require 'prometheus/client/formats/text' +require 'monitoring/prometheus' + +class SampleMetric + def setup(registry, pubsub) + registry.register(::Prometheus::Client::Gauge.new( + :test_gauge, + docstring: '...', + labels: [:test_label] + )) + + pubsub.subscribe("sample_test_gauge") do |payload| + metric = registry.get(:test_gauge) + metric.set(payload[:value], labels: payload[:labels]) + end + end +end + +describe Monitoring::Prometheus do + let(:registry) { + Monitoring::Prometheus.setup + Monitoring::Prometheus.registry + } + + it 'creates a valid registry and allows metrics' do + gauge = registry.gauge(:foo, docstring: '...', labels: [:bar]) + gauge.set(21.534, labels: { bar: 'test' }) + + expect(gauge.get(labels: { bar: 'test' })).to eql(21.534) + end + + it 'can use Pub/Sub events to update metrics on the registry' do + gauge = registry.gauge(:foo, docstring: '...', labels: [:bar]) + + pub_sub = Monitoring::PubSub.instance + pub_sub.subscribe("foo_event_name") do |payload| + labels = { + bar: payload[:bar] + } + gauge.set(payload[:value], labels: labels) + end + + pub_sub.publish("foo_event_name", value: 100, bar: "omicron") + expect(gauge.get(labels: { bar: "omicron" })).to eql(100.0) + end + + context 'when given a list of metrics to setup' do + before do + @metric_obj = SampleMetric.new + @registry = ::Prometheus::Client::Registry.new + @mock_pubsub = double("Mock Monitoring::PubSub") + end + + def prometheus_setup + Monitoring::Prometheus.setup( + registry: @registry, + metrics: [ @metric_obj ], + pubsub: @mock_pubsub + ) + end + + it 'calls .setup for the metric class' do + expect(@metric_obj).to receive(:setup).with(@registry, @mock_pubsub) + prometheus_setup + end + + it 'adds custom metric definitions to the global registry and subscribes to related Pub/Sub events' do + expect(@mock_pubsub).to receive(:subscribe).with("sample_test_gauge") + prometheus_setup + + sample_metric = @registry.get(:test_gauge) + expect(sample_metric).not_to be_nil + end + end +end diff --git a/spec/lib/monitoring/query_helper_spec.rb b/spec/lib/monitoring/query_helper_spec.rb index bbbb37c5d2..b63fecce7c 100644 --- a/spec/lib/monitoring/query_helper_spec.rb +++ b/spec/lib/monitoring/query_helper_spec.rb @@ -1,37 +1,46 @@ require 'monitoring/query_helper' require 'spec_helper' -describe Monitoring::QueryHelper, type: :request do - let(:queryhelper) { Monitoring::QueryHelper.instance } +RSpec.describe Monitoring::QueryHelper do + describe '#policy_resource_counts' do + it 'returns the correct resource counts' do + allow(Resource).to receive(:group_and_count).and_return([ + { kind: 'resource1', count: 10 }, + { kind: 'resource2', count: 20 } + ]) - let(:policies_url) { '/policies/rspec/policy/root' } + counts = Monitoring::QueryHelper.instance.policy_resource_counts - let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + expect(counts).to eq({ 'resource1' => 10, 'resource2' => 20 }) + end - let(:token_auth_header) do - bearer_token = Slosilo["authn:rspec"].signed_token(current_user.login) - token_auth_str = - "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" - { 'HTTP_AUTHORIZATION' => token_auth_str } - end + it 'returns an empty hash if there are no resources' do + allow(Resource).to receive(:group_and_count).and_return([]) - def headers_with_auth(payload) - token_auth_header.merge({ 'RAW_POST_DATA' => payload }) - end + counts = Monitoring::QueryHelper.instance.policy_resource_counts - before do - Slosilo["authn:rspec"] ||= Slosilo::Key.new - post(policies_url, env: headers_with_auth('[!group test]')) + expect(counts).to eq({}) + end end - it 'returns policy resource counts' do - resource_counts = queryhelper.policy_resource_counts - expect(resource_counts['group']).to equal(1) - end + describe '#policy_role_counts' do + it 'returns the correct role counts' do + allow(Role).to receive(:group_and_count).and_return([ + { kind: 'role1', count: 5 }, + { kind: 'role2', count: 15 } + ]) - it 'returns policy role counts' do - role_counts = queryhelper.policy_role_counts - expect(role_counts['group']).to equal(1) - end + counts = Monitoring::QueryHelper.instance.policy_role_counts + expect(counts).to eq({ 'role1' => 5, 'role2' => 15 }) + end + + it 'returns an empty hash if there are no roles' do + allow(Role).to receive(:group_and_count).and_return([]) + + counts = Monitoring::QueryHelper.instance.policy_role_counts + + expect(counts).to eq({}) + end + end end From 4a74e76afc14b9346a701cddfa0fb47725383c17 Mon Sep 17 00:00:00 2001 From: telday Date: Thu, 27 Jul 2023 13:03:09 -0600 Subject: [PATCH 182/665] Add flag to conjurctl server to skip migrations (cherry picked from commit 2324d85d7db7c5e8550cbb7861844081f47a6ccf) --- CHANGELOG.md | 5 ++ bin/conjur-cli.rb | 7 ++- bin/conjur-cli/commands/server.rb | 10 +++- spec/conjurctl/commands/server_spec.rb | 76 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 spec/conjurctl/commands/server_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 40523744e6..617908bfe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Telemetry support [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) +### Added +- New flag to `conjurctl server` command called `--no-migrate` which allows for skipping + the database migration step when starting the server. + [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) + ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) diff --git a/bin/conjur-cli.rb b/bin/conjur-cli.rb index 8e898b7399..213b864e26 100644 --- a/bin/conjur-cli.rb +++ b/bin/conjur-cli.rb @@ -42,6 +42,10 @@ c.default_value(ENV['PORT'] || '80') c.flag [ :p, :port ] + c.desc 'Skip running database migrations on start' + c.default_value false + c.switch :'no-migrate' + c.desc 'Server bind address' c.default_value(ENV['BIND_ADDRESS'] || '0.0.0.0') c.arg_name :ip @@ -55,7 +59,8 @@ password_from_stdin: options["password-from-stdin"], file_name: options[:file], bind_address: options[:'bind-address'], - port: options[:port] + port: options[:port], + no_migrate: options[:'no-migrate'] ) end end diff --git a/bin/conjur-cli/commands/server.rb b/bin/conjur-cli/commands/server.rb index 2716d0ea47..1100d18b08 100644 --- a/bin/conjur-cli/commands/server.rb +++ b/bin/conjur-cli/commands/server.rb @@ -1,16 +1,19 @@ # frozen_string_literal: true require 'command_class' +require 'sequel' # Required to use $CHILD_STATUS require 'English' require_relative 'db/migrate' +require_relative 'connect_database' module Commands Server ||= CommandClass.new( dependencies: { - migrate_database: DB::Migrate.new + migrate_database: DB::Migrate.new, + connect_database: ConnectDatabase.new }, inputs: %i[ @@ -19,6 +22,7 @@ module Commands file_name bind_address port + no_migrate ] ) do def call @@ -26,7 +30,9 @@ def call # and the schema is up-to-date @migrate_database.call( preview: false - ) + ) unless @no_migrate + + @connect_database.call if @no_migrate # Create and bootstrap the initial # Conjur account and policy diff --git a/spec/conjurctl/commands/server_spec.rb b/spec/conjurctl/commands/server_spec.rb new file mode 100644 index 0000000000..ab49ebe7ef --- /dev/null +++ b/spec/conjurctl/commands/server_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +require 'commands/server' + +describe Commands::Server do + + let(:account) { "demo" } + let(:password_from_stdin) { false } + let(:file_name) { nil } + let(:bind_address) { "0.0.0.0" } + let(:port) { 80 } + let(:no_migrate) { false } + + let(:migrate_database) { + double('DB::Migrate').tap do |migrate| + allow(migrate).to receive(:call).with(preview: false) + end + } + let(:connect_database) { + double('ConnectDatabase').tap do |connect| + allow(connect).to receive(:call) + end + } + + before do + # Squash process forking for these tests as we have not implemented a full test + # suite and it causes issues + allow(Process).to receive(:fork).and_return(nil) + allow(Process).to receive(:waitall).and_return(nil) + end + + def delete_account(name) + system("conjurctl account delete #{name}") + end + + after(:each) do + delete_account("demo") + end + + + subject do + Commands::Server.new( + migrate_database: migrate_database, + connect_database: connect_database + ).call( + account: account, + password_from_stdin: password_from_stdin, + file_name: file_name, + bind_address: bind_address, + port: port, + no_migrate: no_migrate + ) + end + + it "performs migrations" do + expect(migrate_database).to receive(:call) + + subject + end + + context "With the no_migrate variable set to true" do + let(:no_migrate) { true } + + it "doesn't perform migrations" do + expect(migrate_database).not_to receive(:call) + + subject + end + + it "connects to the database" do + expect(connect_database).to receive(:call) + + subject + end + end +end From cc90a3490f23d01aea9f0044e0fb211d61664eb7 Mon Sep 17 00:00:00 2001 From: egvili Date: Wed, 9 Aug 2023 13:41:39 +0300 Subject: [PATCH 183/665] Support plural syntax for revoke and deny (cherry picked from commit 4b724b56e20101614c949a946b1ed3d0ad0a426d) --- cucumber/policy/features/deletion.feature | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/cucumber/policy/features/deletion.feature b/cucumber/policy/features/deletion.feature index 9f6ac3b285..818d8dd102 100644 --- a/cucumber/policy/features/deletion.feature +++ b/cucumber/policy/features/deletion.feature @@ -309,3 +309,62 @@ Feature: Deleting objects and relationships. record: !variable to_be_deleted """ Then variable "to_be_deleted" does not exist + And I list the roles permitted to read variable "db/password" + Then the role list does not include host "host-01" + + @smoke + Scenario: The bulk !deny statement can be used to revoke a permission from roles and members. + Given I load a policy: + """ + - !variable db/address + - !variable db/username + - !variable db/password + - !host host-01 + - !host host-02 + - !host host-03 + - !permit + resources: + - !variable db/address + - !variable db/username + - !variable db/password + privileges: [ update ] + roles: + - !host host-01 + - !host host-02 + - !host host-03 + """ + And I list the roles permitted to update variable "db/address" + Then the role list includes host "host-01" + Then the role list includes host "host-02" + Then the role list includes host "host-03" + And I list the roles permitted to update variable "db/username" + Then the role list includes host "host-01" + Then the role list includes host "host-02" + Then the role list includes host "host-03" + And I list the roles permitted to update variable "db/password" + Then the role list includes host "host-01" + Then the role list includes host "host-02" + Then the role list includes host "host-03" + And I update the policy with: + """ + - !deny + resources: + - !variable db/address + - !variable db/username + privileges: [ update ] + roles: + - !host host-01 + - !host host-02 + """ + When I list the roles permitted to update variable "db/address" + Then the role list does not include host "host-01" + And the role list does not include host "host-02" + And the role list includes host "host-03" + When I list the roles permitted to update variable "db/username" + Then the role list does not include host "host-01" + And the role list does not include host "host-02" + And the role list includes host "host-03" + When I list the roles permitted to update variable "db/password" + Then the role list includes host "host-01" + And the role list includes host "host-02" + And the role list includes host "host-03" From e39659b84164abad287a53a86781bb583817f591 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 28 Jul 2023 13:09:49 -0400 Subject: [PATCH 184/665] Cleanup: Consolidate existing sequel config into initializer (cherry picked from commit 28df91c848cdb6fbb6f34528ea3d5d96a8debd4e) --- config/application.rb | 17 ----------------- config/initializers/sequel.rb | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/config/application.rb b/config/application.rb index 0d05d3dd47..3ccf072ddd 100644 --- a/config/application.rb +++ b/config/application.rb @@ -43,26 +43,9 @@ class Application < Rails::Application config.autoload_paths << Rails.root.join('lib') - config.sequel.after_connect = proc do - Sequel.extension(:core_extensions, :postgres_schemata) - Sequel::Model.db.extension(:pg_array, :pg_inet) - end - - #The default connection pool does not support closing connections. - # We must be able to close connections on demand to clear the connection cache - # after policy loads [cyberark/conjur#2584](https://github.com/cyberark/conjur/pull/2584) - # The [ShardedThreadedConnectionPool](https://www.rubydoc.info/github/jeremyevans/sequel/Sequel/ShardedThreadedConnectionPool) does support closing connections on-demand. - # Sequel is configured to use the ShardedThreadedConnectionPool by setting the servers configuration on - # the database connection [docs](https://www.rubydoc.info/github/jeremyevans/sequel/Sequel%2FShardedThreadedConnectionPool:servers) - config.sequel.servers = {} - config.encoding = "utf-8" config.active_support.escape_html_entities_in_json = true - # Whether to dump the schema after successful migrations. - # Defaults to false in production and test, true otherwise. - config.sequel.schema_dump = false - # Sets all the blank Environment Variables to nil. This ensures that nil # checks are sufficient to verify the usage of an environment variable. ENV.each_pair do |(k, v)| diff --git a/config/initializers/sequel.rb b/config/initializers/sequel.rb index 382cf2798f..ef648bfdf5 100644 --- a/config/initializers/sequel.rb +++ b/config/initializers/sequel.rb @@ -10,3 +10,22 @@ def write_id_to_json response, field response[field] = value if value end end + +Rails.application.configure do + config.sequel.after_connect = proc do + Sequel.extension(:core_extensions, :postgres_schemata) + Sequel::Model.db.extension(:pg_array, :pg_inet) + end + + # The default connection pool does not support closing connections. + # We must be able to close connections on demand to clear the connection cache + # after policy loads [cyberark/conjur#2584](https://github.com/cyberark/conjur/pull/2584) + # The [ShardedThreadedConnectionPool](https://www.rubydoc.info/github/jeremyevans/sequel/Sequel/ShardedThreadedConnectionPool) does support closing connections on-demand. + # Sequel is configured to use the ShardedThreadedConnectionPool by setting the servers configuration on + # the database connection [docs](https://www.rubydoc.info/github/jeremyevans/sequel/Sequel%2FShardedThreadedConnectionPool:servers) + config.sequel.servers = {} + + # Whether to dump the schema after successful migrations. + # Defaults to false in production and test, true otherwise. + config.sequel.schema_dump = false +end From 6f1ecf85cb3a9e706e9246dd3a68054c27976792 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 28 Jul 2023 20:21:01 +0300 Subject: [PATCH 185/665] Cleanup: Fix issues in the changelog This fixes a "Fixed" block under the "Unreleased" section and a duplicate "Added" block in the version currently under development. (cherry picked from commit 4be5ac451f8d7742f98d41d7fc003d06eec52e28) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 617908bfe7..ca8da499a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,8 +141,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Telemetry support [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) - -### Added - New flag to `conjurctl server` command called `--no-migrate` which allows for skipping the database migration step when starting the server. [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) @@ -150,6 +148,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) +- Support plural syntax for revoke and deny + [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) ## [1.19.5] - 2023-06-29 From 7deae0192920cfb5460874b6768c5c1dc4a9a581 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 9 Aug 2023 17:12:10 -0400 Subject: [PATCH 186/665] Improve input validation and error messages Rather than raise an ArgumentError, raise a message with details on which configuration value failed to parse and how to fix it. (cherry picked from commit ce8fa185a95cb358c1b8b96ee5afcd33c880bb0f) --- config/puma.rb | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/config/puma.rb b/config/puma.rb index b7d9f911f0..f8b8c5579e 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,7 +1,24 @@ # frozen_string_literal: true -workers Integer(ENV['WEB_CONCURRENCY'] || 2) -threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5) +begin + workers Integer(ENV['WEB_CONCURRENCY'] || 2) +rescue ArgumentError + raise( + "Invalid value for WEB_CONCURRENCY environment variable: " \ + "'#{ENV['WEB_CONCURRENCY']}'. " \ + "Value must be a positive integer (default is 2)." + ) +end + +begin + threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5) +rescue ArgumentError + raise( + "Invalid value for RAILS_MAX_THREADS environment variable: " \ + "'#{ENV['RAILS_MAX_THREADS']}'. " \ + "Value must be a positive integer (default is 5)." + ) +end threads threads_count, threads_count # The tag is displayed in the Puma process description, for example: From bb9123ec53e71bc98a842e27a355cca468606bc5 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 9 Aug 2023 17:12:32 -0400 Subject: [PATCH 187/665] Use Conjur application settings in the appliance Rather than configure puma directly in the appliance, use the Conjur application settings and config to set the puma threads. (cherry picked from commit 0336785c8b1c167a26f0315d45d908501c4156a4) --- distrib/conjur/etc/possum.conf | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/distrib/conjur/etc/possum.conf b/distrib/conjur/etc/possum.conf index 922058e79f..1909707bbb 100644 --- a/distrib/conjur/etc/possum.conf +++ b/distrib/conjur/etc/possum.conf @@ -1,4 +1,2 @@ -PUMA_THREAD_MIN=0 -PUMA_THREAD_MAX=16 +RAILS_MAX_THREADS=16 PORT=5000 - From 14fe34b5c480fb222e3f8f6a0ff829cf19e83694 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Aug 2023 14:23:43 -0400 Subject: [PATCH 188/665] Set the db connection pool size based on the worker thread count (cherry picked from commit 58f8db51a28d1bb85ab858b3132c4f7586a56329) --- CHANGELOG.md | 7 +++++++ config/initializers/sequel.rb | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8da499a2..0577f95f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. the database migration step when starting the server. [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) +### Changed +- The database thread pool max connection size is now based on the number of + web worker threads per process, rather than an arbitrary fixed number. This + mitigates the possibility of a web worker becoming starved while waiting for + a connection to become available. + [cyberark/conjur#2875](https://github.com/cyberark/conjur/pull/2875) + ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) diff --git a/config/initializers/sequel.rb b/config/initializers/sequel.rb index ef648bfdf5..a15d1f8492 100644 --- a/config/initializers/sequel.rb +++ b/config/initializers/sequel.rb @@ -28,4 +28,28 @@ def write_id_to_json response, field # Whether to dump the schema after successful migrations. # Defaults to false in production and test, true otherwise. config.sequel.schema_dump = false + + # Max Postgres connections should be no less than the number of threads + # available to the web worker to avoid pool timeouts. + begin + threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 16) + rescue ArgumentError + raise( + "Invalid value for RAILS_MAX_THREADS environment variable: " \ + "'#{ENV['RAILS_MAX_THREADS']}'. " \ + "Value must be a positive integer (default is 16)." + ) + end + + begin + connections_per_thread = Float(ENV['DATABASE_CONNECTIONS_PER_THREAD'] || 1.2) + rescue ArgumentError + raise( + "Invalid value for DATABASE_CONNECTIONS_PER_THREAD environment variable: " \ + "'#{ENV['DATABASE_CONNECTIONS_PER_THREAD']}'. " \ + "Value must be a positive decimal (default is 1.2)." + ) + end + + config.sequel.max_connections = (threads_count * connections_per_thread).ceil end From ef5e2d7263b6ff1682fd985f66a3ca2b89d22a60 Mon Sep 17 00:00:00 2001 From: ygeva Date: Sun, 15 Oct 2023 21:54:51 +0300 Subject: [PATCH 189/665] Return conflict for existing issuer when variables associated with it --- CHANGELOG.md | 1 + app/controllers/issuers_controller.rb | 9 +++++++-- spec/controllers/issuers_controller_spec.rb | 7 ++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0577f95f57..5b15457121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [1.0.10-cloud] - 2023-10-22 ### Added - Telemetry logs for ephemeral secrets +- Return conflict for existing issuer when variables associated with it ## [1.0.9-cloud] - 2023-10-15 ### Added diff --git a/app/controllers/issuers_controller.rb b/app/controllers/issuers_controller.rb index 95a7141864..e1d0392d94 100644 --- a/app/controllers/issuers_controller.rb +++ b/app/controllers/issuers_controller.rb @@ -29,6 +29,11 @@ def create issuer_type = IssuerTypeFactory.new.create_issuer_type(params[:type]) issuer_type.validate(body_params) + issuerResource = Issuer.find(issuer_id: params[:id]) + if not issuerResource.nil? + raise Exceptions::RecordExists.new("issuer", params[:id]) + end + issuer = Issuer.new(issuer_id: params[:id], account: params[:account], issuer_type: params[:type], max_ttl: params[:max_ttl], data: params[:data].to_json, @@ -58,11 +63,11 @@ def create message: e.message } }, status: :bad_request) - rescue Sequel::UniqueConstraintViolation => e + rescue Exceptions::RecordExists => e logger.error("The issuer [#{params[:id]}] already exists") audit_failure(e, action) issuer_audit_failure(params[:account], params[:id], "add", e.message) - raise Exceptions::RecordExists.new("issuer", params[:id]) + raise e rescue => e audit_failure(e, action) issuer_audit_failure(params[:account], params[:id], "add", e.message) diff --git a/spec/controllers/issuers_controller_spec.rb b/spec/controllers/issuers_controller_spec.rb index 7a8f8de679..f5e07e7d25 100644 --- a/spec/controllers/issuers_controller_spec.rb +++ b/spec/controllers/issuers_controller_spec.rb @@ -181,7 +181,12 @@ ) assert_response :success expect(Resource.find(resource_id: "rspec:variable:data/ephemerals/related-ephemeral-variable")).to_not eq(nil) - + post("/issuers/rspec", + env: token_auth_header(role: admin_user).merge( + 'RAW_POST_DATA' => payload_create_issuer_input, + 'CONTENT_TYPE' => "application/json" + )) + assert_response :conflict end end From 832d2863d0bb6573be4a0e22430277467c986a0d Mon Sep 17 00:00:00 2001 From: Jason Vanderhoof Date: Fri, 14 Jul 2023 15:04:45 -0600 Subject: [PATCH 190/665] Initial implementation of Policy Factories This commit includes the functional code and tests for the Policy Factory feature. (cherry picked from commit f980350997efa8f22f6e66391b899d3c51062c33) --- CHANGELOG.md | 7 + Gemfile | 2 +- Gemfile.lock | 23 +- .../policy_factories_controller.rb | 69 +++ .../repository/policy_factory_repository.rb | 131 +++++ app/domain/factories/Readme.md | 555 ++++++++++++++++++ .../factories/create_from_policy_factory.rb | 200 +++++++ app/domain/factories/images/Basic-Sample.png | Bin 0 -> 27463 bytes app/domain/factories/images/Readme-5.png | Bin 0 -> 73308 bytes .../images/factory-create-request.png | Bin 0 -> 108786 bytes .../factories/images/factory-info-request.png | Bin 0 -> 37919 bytes .../factories/images/factory-list-request.png | Bin 0 -> 35335 bytes app/domain/factories/images/factory-setup.png | Bin 0 -> 30473 bytes .../factories/images/factory-upgrade.png | Bin 0 -> 22225 bytes app/domain/factories/renderer.rb | 23 + app/domain/responses.rb | 54 ++ app/presenters/policy_factories/error.rb | 28 + app/presenters/policy_factories/index.rb | 35 ++ app/presenters/policy_factories/show.rb | 20 + config/routes.rb | 5 + lib/tasks/policy_factory.rake | 70 +++ .../policy_factory_repository_spec.rb | 421 +++++++++++++ .../create_from_policy_factory_spec.rb | 397 +++++++++++++ spec/app/domain/factories/renderer_spec.rb | 98 ++++ spec/app/domain/responses_spec.rb | 142 +++++ .../presenters/policy_factories/error_spec.rb | 40 ++ .../presenters/policy_factories/index_spec.rb | 47 ++ .../presenters/policy_factories/show_spec.rb | 60 ++ .../policy_factories_controller_spec.rb | 201 +++++++ 29 files changed, 2621 insertions(+), 7 deletions(-) create mode 100644 app/controllers/policy_factories_controller.rb create mode 100644 app/db/repository/policy_factory_repository.rb create mode 100644 app/domain/factories/Readme.md create mode 100644 app/domain/factories/create_from_policy_factory.rb create mode 100644 app/domain/factories/images/Basic-Sample.png create mode 100644 app/domain/factories/images/Readme-5.png create mode 100644 app/domain/factories/images/factory-create-request.png create mode 100644 app/domain/factories/images/factory-info-request.png create mode 100644 app/domain/factories/images/factory-list-request.png create mode 100644 app/domain/factories/images/factory-setup.png create mode 100644 app/domain/factories/images/factory-upgrade.png create mode 100644 app/domain/factories/renderer.rb create mode 100644 app/domain/responses.rb create mode 100644 app/presenters/policy_factories/error.rb create mode 100644 app/presenters/policy_factories/index.rb create mode 100644 app/presenters/policy_factories/show.rb create mode 100644 lib/tasks/policy_factory.rake create mode 100644 spec/app/db/repository/policy_factory_repository_spec.rb create mode 100644 spec/app/domain/factories/create_from_policy_factory_spec.rb create mode 100644 spec/app/domain/factories/renderer_spec.rb create mode 100644 spec/app/domain/responses_spec.rb create mode 100644 spec/app/presenters/policy_factories/error_spec.rb create mode 100644 spec/app/presenters/policy_factories/index_spec.rb create mode 100644 spec/app/presenters/policy_factories/show_spec.rb create mode 100644 spec/controllers/policy_factories_controller_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b15457121..29ceb74737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Telemetry support [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) +- Introduces support for Policy Factory, which enables resource creation + through a new `factories` API. + [cyberark/conjur#2855](https://github.com/cyberark/conjur/pull/2855/files) + +## [1.19.6] - 2023-07-05 + +### Added - New flag to `conjurctl server` command called `--no-migrate` which allows for skipping the database migration step when starting the server. [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) diff --git a/Gemfile b/Gemfile index e3a33d72c4..cf65ea3726 100644 --- a/Gemfile +++ b/Gemfile @@ -78,7 +78,7 @@ gem 'openid_connect' gem "anyway_config" gem 'i18n', '~> 1.8.11' - +gem 'json_schemer' gem 'prometheus-client' group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index 08eed7f34e..9c6ec41544 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,8 +80,8 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) aes_key_wrap (1.1.0) anyway_config (2.2.3) ruby-next-core (>= 0.14.0) @@ -124,7 +124,7 @@ GEM coderay (1.1.3) command_class (0.0.2) concurrent-ruby (1.2.2) - conjur-api (5.3.8.pre.194) + conjur-api (5.4.0) activesupport (>= 4.2) addressable (~> 2.0) rest-client @@ -215,6 +215,8 @@ GEM dry-core (~> 0.5, >= 0.5) dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.0, >= 1.0.2) + ecma-re-validator (0.4.0) + regexp_parser (~> 2.2) erubi (1.12.0) et-orbi (1.2.7) tzinfo @@ -235,6 +237,7 @@ GEM globalid (1.1.0) activesupport (>= 5.0) haikunator (1.1.1) + hana (1.3.7) hashdiff (1.0.1) highline (2.0.3) http (4.2.0) @@ -243,7 +246,7 @@ GEM http-form_data (~> 2.0) http-parser (~> 1.2.0) http-accept (1.7.0) - http-cookie (1.0.4) + http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) http-parser (1.2.3) @@ -262,6 +265,11 @@ GEM activesupport (>= 4.2) aes_key_wrap bindata + json_schemer (0.2.24) + ecma-re-validator (~> 0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + uri_template (~> 0.7) json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) @@ -337,7 +345,7 @@ GEM pry (~> 0.13.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.6) + public_suffix (5.0.1) puma (5.6.4) nio4r (~> 2.0) raabro (1.4.0) @@ -401,6 +409,7 @@ GEM kwalify (~> 0.7.0) parser (~> 3.0.0) rainbow (>= 2.0, < 4.0) + regexp_parser (2.7.0) rest-client (2.1.0) http-accept (>= 1.7.0, < 2.0) http-cookie (>= 1.0.2, < 2.0) @@ -486,8 +495,9 @@ GEM concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.1) + unf_ext (0.0.8.2) unicode-display_width (1.8.0) + uri_template (0.7.0) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -547,6 +557,7 @@ DEPENDENCIES i18n (~> 1.8.11) iso8601 jbuilder (~> 2.7.0) + json_schemer json_spec (~> 1.1) jwt (= 2.2.2) kubeclient diff --git a/app/controllers/policy_factories_controller.rb b/app/controllers/policy_factories_controller.rb new file mode 100644 index 0000000000..0e73566d4d --- /dev/null +++ b/app/controllers/policy_factories_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require './app/domain/responses' + +class PolicyFactoriesController < RestController + include AuthorizeResource + + before_action :current_user + + def create + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(%i[account kind version id]) + ).bind do |factory| + Factories::CreateFromPolicyFactory.new.call( + account: params[:account], + factory_template: factory, + request_body: request.body.read, + authorization: request.headers["Authorization"] + ) + end + + render_response(response) do + render(json: response.result) + end + end + + def show + allowed_params = %i[account kind version id] + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(allowed_params) + ) + + render_response(response) do + presenter = Presenter::PolicyFactories::Show.new(factory: response.result) + render(json: presenter.present) + end + end + + def index + response = DB::Repository::PolicyFactoryRepository.new.find_all( + role: current_user, + account: params[:account] + ) + render_response(response) do + presenter = Presenter::PolicyFactories::Index.new(factories: response.result) + render(json: presenter.present) + end + end + + private + + def render_response(response, &block) + if response.success? + block.call + else + presenter = Presenter::PolicyFactories::Error.new(response: response) + render( + json: presenter.present, + status: response.status + ) + end + end + + def relevant_params(allowed_params) + params.permit(*allowed_params).slice(*allowed_params).to_h.symbolize_keys + end +end diff --git a/app/db/repository/policy_factory_repository.rb b/app/db/repository/policy_factory_repository.rb new file mode 100644 index 0000000000..61bb003a22 --- /dev/null +++ b/app/db/repository/policy_factory_repository.rb @@ -0,0 +1,131 @@ +require 'base64' +require 'json' + +require './app/domain/responses' + +module DB + module Repository + module DataObjects + PolicyFactory = Struct.new( + :name, + :classification, + :version, + :policy, + :policy_branch, + :schema, + :description, + keyword_init: true + ) + end + + class PolicyFactoryRepository + def initialize( + data_object: DataObjects::PolicyFactory, + resource: ::Resource, + logger: Rails.logger + ) + @resource = resource + @data_object = data_object + @logger = logger + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def find_all(account:, role:) + factories = @resource.visible_to(role).where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/%" + ) + ).all + .select { |factory| role.allowed_to?(:execute, factory) } + .group_by do |item| + # form is: 'conjur/factories/core/v1/groups' + _, _, classification, _, factory = item.resource_id.split('/') + [classification, factory].join('/') + end + .map do |_, versions| + versions.max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + .map do |factory| + response = secret_to_data_object(factory) + response.result if response.success? + end + .compact + + if factories.empty? + return @failure.new( + 'Role does not have permission to use Factories', + status: :forbidden + ) + end + + @success.new(factories) + end + + def find(kind:, id:, account:, role:, version: nil) + factory = if version.present? + @resource["#{account}:variable:conjur/factories/#{kind}/#{version}/#{id}"] + else + @resource.where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/#{kind}/%" + ) + ).all + .select { |i| i.resource_id.split('/').last == id } + .max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + + resource_id = "#{kind}/#{version || 'v1'}/#{id}" + + if factory.blank? + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory does not exist' }, + status: :not_found + ) + elsif !role.allowed_to?(:execute, factory) + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory is not available' }, + status: :forbidden + ) + else + secret_to_data_object(factory) + end + end + + private + + def factory_version(factory_id) + version_match = factory_id.match(%r{/v(\d+)/[\w-]+}) + return 0 if version_match.nil? + + version_match[1].to_i + end + + def secret_to_data_object(variable) + _, _, classification, version, id = variable.resource_id.split('/') + factory = variable.secret&.value + if factory + decoded_factory = JSON.parse(Base64.decode64(factory)) + @success.new( + @data_object.new( + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'], + schema: decoded_factory['schema'], + version: version, + name: id, + classification: classification, + description: decoded_factory['schema']&.dig('description').to_s + ) + ) + else + @failure.new( + { resource: "#{classification}/#{version}/#{id}", message: 'Requested Policy Factory is not available' }, + status: :bad_request + ) + end + end + end + end +end diff --git a/app/domain/factories/Readme.md b/app/domain/factories/Readme.md new file mode 100644 index 0000000000..a5a95b519c --- /dev/null +++ b/app/domain/factories/Readme.md @@ -0,0 +1,555 @@ +# Policy Factory + +## Setup + +Setup will follow the following workflow: + +![Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## Factory Upgrade + +Upgrades will follow the following workflow: + +![Factory Upgrade](./images/factory-upgrade.png) + +```plantuml +@startuml factory-upgrade +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy with new factory versions; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## View all Policy Factories + +A role is limited to viewing the Factories they have permission (`execute`) to see. +If a role can see a factory, they will be able to see errors in mis-configured Factories. + +![Factory List Request](./images/factory-list-request.png) + +```plantuml +@startuml factory-list-request +start +:Identify target Factory based on request params; +:Gather factories the role is able to view; +partition "For each Factory Version" { + repeat + if ("Factory is present?") then (yes) + if ("Is Factory format is valid?") then (yes) + if ("Is Factory Schema is valid?") then (yes) + :Display Factory details and Schema; + else + #pink:[Error] Invalid Factory Schema; + endif + else + #pink:[Error] Invalid Factory Format; + endif + else + #pink:[Error] Factory not Defined; + endif + backward: Next Factory; + repeat while (More Factories?) +} +:Return JSON Summary; +@enduml +``` + +## Policy Factory Info Requests + +![Factory Info Request](./images/factory-info-request.png) + +```plantuml +@startuml factory-info-request +(*) --> "Identify target Factory based on request params" +if "Does Factory exist?" then + --> [yes] if "Role has permission to view factory" then + --> [yes] if "Factory is present?" then + --> "Load Factory" + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Return Schema" + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + endif + else + --> [no] "[Error] Factory not Available" + endif +else + --> [no] "[Error] Factory not Found" +endif +@enduml + +``` + +## Policy Factory Creation Requests + +![Factory Create Request](./images/factory-create-request.png) + +```plantuml +@startuml factory-create-request +(*) --> "Identify Factory variable based on request params" +if "Does factory variable exist?" then + --> [yes] if "Can role load factory?" then + --> [yes] "Load Factory" + --> [yes] if "Does factory variable have a value?" then + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Extract Schema from Factory Variable" + --> "Parse [POST] JSON Request body" + --> if "is JSON valid?" + --> [yes] if "Required keys present?" + --> [yes] if "Required values present?" + --> [yes] if "Policy rendered successfully?" + --> [yes] if "Policy namespace path rendered successfully?" + --> [yes] if "Policy successfully applied" + --> [yes] if "Factory has variables?" + --> [yes] if "Variables set successfully set?" + --> "Return Policy and Variable response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + --> (*) + else + --> [no] "[Error] Setting Variable(s) not Permitted" + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + endif + else + --> [no] " Policy Created Response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + endif + else + --> [no] "[Error] Policy Creation not Permitted" + ' note left + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + endif + else + --> [no] "[Error] Invalid Factory Namespace ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Invalid Factory Policy ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Missing Required Values" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Missing Required Keys" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Bad Request Body" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + endif + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + endif + else + --> [no] "[Error] Factory not Available" + ' note left + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + endif +else + --> [no] "[Error] Factory not Found" + ' note left + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note +endif +@enduml +``` + +## Policy Factory Creation Requests (beta) + +![Policy Factory Create Request](./images/Readme-5.png) + +```plantuml +@startuml +start +:Identify Factory\nvariable based\non request params; +if (Does factory variable exist?) then (yes) + if (Can role load factory variable?) then (yes) + if (Does factory variable have a value?) then (yes) + :Load Factory; + :Extract Schema from Factory Variable; + :Parse [POST] JSON Request body; + ' :Extract Schema from Factory; + if (Parse JSON body?) then (yes) + if (Required keys missing?) then (no) + #pink: Missing Keys; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (required values empty?) then (no) + #pink: Missing Values; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (Policy rendered?) then (yes) + if (Policy namespace path rendered?) then (yes) + if (Policy successfully applied) then (yes) + if (Factory has variables?) then (yes) + if (Variable successfully set?) then (yes) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + end + else (no) + #pink: Setting Variable(s) not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + kill + endif + else (no) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + kill + endif + else (no) + #pink: Policy Creation not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + kill + endif + else (no) + #pink: Invalid Policy Namespace ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + kill + endif + else (no) + #pink: Invalid Policy ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + kill + endif + endif + endif + else (no) + #pink: Malformed JSON; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + kill + endif + else (no) + #pink: Factory Variable empty; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + kill + endif + else (no) + #pink: Factory not available; + ' note right + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + kill + endif +else (no) + #pink: Factory Variable not present; + ' note right + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note + kill +endif +@enduml +``` + +### UI Workflow + +![UI Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Login; +:Navigate to "Policy Factories" page; +if (Can view Factories) then (yes) + :Show Factory Groupings; + :Navigate to Factory Grouping; + :Select a Factory; + if ("Can view Factory") then (yes) + :View Factory form; + if ("Factory successfully created") then (yes) + :Redirect + else + end + else + end +else (no) + :Show empty Factories page\nwith "No Factories Available"; +end +@enduml +``` + +## Code Architecture + +![Basic Overview](./images/Basic-Sample.png) + +```plantuml +@startuml Basic Sample +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +Component(controller, "PolicyFactoryController", "Rails", "Routes requests to Business Logic and renders results") + +Component(repository, "PolicyFactoryRepository", "Ruby", "Retrieves Factories from Conjur Variables") + +Component(data_object, "DataObjects::PolicyFactory", "Ruby") + +Component(create, "CreateFromPolicyFactory", "Ruby", "Generates Conjur elements using a Policy Factory") + +Rel(repository, controller, "loads factory from") + +' Component(repository 'PolicyFactoryRepository') + +' component PolicyFactoryController +' component PolicyFactoryRepository +@enduml +``` diff --git a/app/domain/factories/create_from_policy_factory.rb b/app/domain/factories/create_from_policy_factory.rb new file mode 100644 index 0000000000..92ded89bde --- /dev/null +++ b/app/domain/factories/create_from_policy_factory.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'rest_client' +require 'json_schemer' +require 'factories/renderer' + +module Factories + class Utilities + def self.filter_input(str) + str.gsub(/[^0-9a-z\-_]/i, '') + end + end + class CreateFromPolicyFactory + def initialize(renderer: Factories::Renderer.new, http: RestClient, schema_validator: JSONSchemer, utilities: Factories::Utilities) + @renderer = renderer + @http = http + @schema_validator = schema_validator + @utilities = utilities + + # JSON and URI are defined here for visibility. They are not currently + # mocked in testing, thus, we're not setting them in the initializer. + @json = JSON + @uri = URI + + # Defined here for visibility. We shouldn't need to mock these. + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def call(factory_template:, request_body:, account:, authorization:) + validate_and_transform_request( + schema: factory_template.schema, + params: request_body + ).bind do |body_variables| + # Convert `dashed` keys to `underscored`. This only occurs for top-level parameters. + # Conjur variables should be use dashes rather than underscores. + # Filter non-alpha-numeric, dash or underscore characters from inputs values (to prevent injection attacks). + template_variables = body_variables + .transform_keys { |key| key.to_s.underscore } + .each_with_object({}) do |(key, value), rtn| + # Only strip values that are rendered in the policy (not Conjur secret values) + rtn[key] = if key == 'variables' + value + elsif value.is_a?(Hash) + value.transform_values { |internal_value| @utilities.filter_input(internal_value.to_s) } + else + @utilities.filter_input(value.to_s) + end + end + + # Add empty `annotations` hash unless they've previously been set + template_variables["annotations"] = {} unless template_variables.include?('annotations') + + # Push rendered policy to the desired policy branch + @renderer.render(template: factory_template.policy_branch, variables: template_variables) + .bind do |policy_load_path| + valid_variables = factory_template.schema['properties'].keys - ['variables'] + render_and_apply_policy( + policy_load_path: policy_load_path, + policy_template: factory_template.policy, + variables: template_variables.select { |k, _| valid_variables.include?(k) }, + account: account, + authorization: authorization + ).bind do |result| + return @success.new(result) unless factory_template.schema['properties'].key?('variables') + + # Set Policy Factory variables + @renderer.render(template: "#{factory_template.policy_branch}/<%= id %>", variables: template_variables) + .bind do |variable_path| + set_factory_variables( + schema_variables: factory_template.schema['properties']['variables']['properties'], + factory_variables: template_variables['variables'], + variable_path: variable_path, + authorization: authorization, + account: account + ) + end + .bind do + # If variables were added successfully, return the result so that + # we send the policy load response back to the client. + @success.new(result) + end + end + end + end + end + + private + + def validate_and_transform_request(schema:, params:) + return @failure.new('Request body must be JSON', status: :bad_request) if params.blank? + + begin + params = @json.parse(params) + rescue + return @failure.new('Request body must be valid JSON', status: :bad_request) + end + + # Strip keys without values + params = params.select{|_, v| v.present? } + validator = @schema_validator.schema(schema) + return @success.new(params) if validator.valid?(params) + + errors = validator.validate(params).map do |error| + case error['type'] + when 'required' + missing_attributes = error['details']['missing_keys'].map{|key| [ error['data_pointer'], key].reject(&:empty?).join('/') } #.join("', '") + missing_attributes.map do |attribute| + { + message: "A value is required for '#{attribute}'", + key: attribute + } + end + else + { + message: "Validation error: '#{error['data_pointer']}' must be a #{error['type']}" + } + end + end + @failure.new(errors.flatten, status: :bad_request) + end + + def render_and_apply_policy(policy_load_path:, policy_template:, variables:, account:, authorization:) + @renderer.render( + template: policy_template, + variables: variables + ).bind do |rendered_policy| + begin + response = @http.post( + "http://localhost:3000/policies/#{account}/policy/#{policy_load_path}", + rendered_policy, + 'Authorization' => authorization + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new( + { message: 'Authentication failed', + request_error: e.response.body }, status: :unauthorized + ) + when 403 + return @failure.new( + { message: "Applying generated policy to '#{policy_load_path}' is not allowed", + request_error: e.response.body }, status: :forbidden + ) + when 404 + return @failure.new( + { message: "Unable to apply generated policy to '#{policy_load_path}'", + request_error: e.response.body }, status: :not_found + ) + else + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + + @success.new(response.body) + end + end + + def set_factory_variables(schema_variables:, factory_variables:, variable_path:, authorization:, account:) + # Only set secrets defined in the policy + schema_variables.each_key do |factory_variable| + variable_id = @uri.encode_www_form_component("#{variable_path}/#{factory_variable}") + secret_path = "secrets/#{account}/variable/#{variable_id}" + + @http.post( + "http://localhost:3000/#{secret_path}", + factory_variables[factory_variable].to_s, + { 'Authorization' => authorization } + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new("Role is unauthorized to set variable: '#{secret_path}'", status: :unauthorized) + when 403 + return @failure.new("Role lacks the privilege to set variable: '#{secret_path}'", status: :forbidden) + else + return @failure.new( + "Failed to set variable: '#{secret_path}'. Status Code: '#{e.response.code}', Response: '#{e.response.body}'", + status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed set variable '#{secret_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + @success.new('Variables successfully set') + end + end +end diff --git a/app/domain/factories/images/Basic-Sample.png b/app/domain/factories/images/Basic-Sample.png new file mode 100644 index 0000000000000000000000000000000000000000..1f5159333206814511c37b85b0bee68db545e6e4 GIT binary patch literal 27463 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Ð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 OOKAxO@!b1H&;JJ!hnGG8 literal 0 HcmV?d00001 diff --git a/app/domain/factories/images/factory-create-request.png b/app/domain/factories/images/factory-create-request.png new file mode 100644 index 0000000000000000000000000000000000000000..d27a669fa7512507dcb1f1b48e5e328a53881617 GIT binary patch literal 108786 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

=EjbU8|)qT$-ht`mu!!_t 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

uil=| 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 W#?lTvu-a@;6ul~S<;|Zu_x=xo@MjJH literal 0 HcmV?d00001 diff --git a/app/domain/factories/images/factory-info-request.png b/app/domain/factories/images/factory-info-request.png new file mode 100644 index 0000000000000000000000000000000000000000..bceab0368c425bc9e91efcea5c3438a3a24e3e3a GIT binary patch literal 37919 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 AH2?qr literal 0 HcmV?d00001 diff --git a/app/domain/factories/images/factory-list-request.png b/app/domain/factories/images/factory-list-request.png new file mode 100644 index 0000000000000000000000000000000000000000..9559793b65375aadc52a16cf0f38bbea4dad3eb6 GIT binary patch literal 35335 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 aLyrWPF?k&K1b&kip{=QZrdZwT*8c!7{JFXS literal 0 HcmV?d00001 diff --git a/app/domain/factories/images/factory-upgrade.png b/app/domain/factories/images/factory-upgrade.png new file mode 100644 index 0000000000000000000000000000000000000000..05e0f50bfb1937e53e4cdebb504f0381ac13e0fb GIT binary patch literal 22225 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 e + @failure.new("Required template variable '#{e.name}' is missing") + rescue => e + # Need to add tests to understand what exceptions are thrown when + # variables are missing. This may not be enough. + @failure.new(e) + end + end +end diff --git a/app/domain/responses.rb b/app/domain/responses.rb new file mode 100644 index 0000000000..4fc9948f17 --- /dev/null +++ b/app/domain/responses.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# These response objects provide a mechanism for passing more complex response +# information upstream + +# Responsible for handling "successful" requests. The +# response is returned via the `.result` method. +class SuccessResponse + attr_reader :result + + def initialize(result) + @result = result + end + + def success? + true + end + + # The result of bind should always be another Response object, if the current + # response object is successful, #bind will call the next operation + def bind(&_block) + yield(result) + end +end + +# Responsible for handling "failed" requests. +# Log level and Response code are both option. +class FailureResponse + attr_reader :message, :status + + def initialize(message, level: :warn, status: :unauthorized) + @message = message + @level = level + @status = status + end + + def success? + false + end + + def level + @level.to_sym + end + + def to_s + @message.to_s + end + + # If the current response is a failure, further attempts to bind will just + # return this response again. + def bind + self + end +end diff --git a/app/presenters/policy_factories/error.rb b/app/presenters/policy_factories/error.rb new file mode 100644 index 0000000000..ed84a45ca0 --- /dev/null +++ b/app/presenters/policy_factories/error.rb @@ -0,0 +1,28 @@ +module Presenter + module PolicyFactories + # Returns a Hash representation of an Failure Response to be used by the controller + class Error + # Response is always a FailureResponse + def initialize(response:, response_codes: HTTP::Response::Status::SYMBOL_CODES) + @response = response + @response_codes = response_codes + end + + def present + { + code: @response_codes[@response.status] + }.tap do |rtn| + rtn[:error] = format_error_message(@response.message) + end + end + + private + + def format_error_message(message) + return message if message.is_a?(Array) || message.is_a?(Hash) + + { message: message.to_s } + end + end + end +end diff --git a/app/presenters/policy_factories/index.rb b/app/presenters/policy_factories/index.rb new file mode 100644 index 0000000000..36fea65755 --- /dev/null +++ b/app/presenters/policy_factories/index.rb @@ -0,0 +1,35 @@ +module Presenter + module PolicyFactories + # returns a Hash representation to be used by the controller + class Index + def initialize(factories:) + @factories = factories + end + + def present + {}.tap do |rtn| + @factories + .group_by(&:classification) + .sort_by {|classification, _| classification } + .map do |classification, factories| + rtn[classification] = factories + .map { |factory| factory_to_hash(factory) } + .sort { |x, y| x[:name] <=> y[:name] } + end + end + end + + private + + def factory_to_hash(factory) + { + name: factory.name, + namespace: factory.classification, + 'full-name': "#{factory.classification}/#{factory.name}", + 'current-version': factory.version, + description: factory.description || '' + } + end + end + end +end diff --git a/app/presenters/policy_factories/show.rb b/app/presenters/policy_factories/show.rb new file mode 100644 index 0000000000..1bfdcd2f00 --- /dev/null +++ b/app/presenters/policy_factories/show.rb @@ -0,0 +1,20 @@ +module Presenter + module PolicyFactories + # returns a hash representation to be used by the controller + class Show + def initialize(factory:) + @factory = factory + end + + def present + { + title: @factory.schema['title'], + version: @factory.version, + description: @factory.schema['description'], + properties: @factory.schema['properties'], + required: @factory.schema['required'] + } + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 0f32a6d808..e4cecf65bb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -52,6 +52,11 @@ def matches?(request) post '/authn-k8s/:service_id/inject_client_cert' => 'authenticate#k8s_inject_client_cert' end + # Factories + post "/factories/:account/:kind/(:version)/:id" => "policy_factories#create" + get "/factories/:account/:kind/(:version)/:id" => "policy_factories#show" + get "/factories/:account" => "policy_factories#index" + get "/roles/:account/:kind/*identifier" => "roles#graph", :constraints => QueryParameterActionRecognizer.new("graph") get "/roles/:account/:kind/*identifier" => "roles#all_memberships", :constraints => QueryParameterActionRecognizer.new("all") get "/roles/:account/:kind/*identifier" => "roles#direct_memberships", :constraints => QueryParameterActionRecognizer.new("memberships") diff --git a/lib/tasks/policy_factory.rake b/lib/tasks/policy_factory.rake new file mode 100644 index 0000000000..2f726cb7d2 --- /dev/null +++ b/lib/tasks/policy_factory.rake @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Factory + module Templates + class ValidateTemplate + def initialize(renderer: Factories::RenderPolicy.new) + @renderer = renderer + end + + def test(factory:, template_params:) + puts('template:') + puts(factory.policy_template) + puts('--------------') + puts('') + puts('rendered template:') + puts(@renderer.render(policy_template: factory.policy_template, variables: template_params)) + + # puts(ERB.new(@factory.policy_template, nil, '-').result_with_hash(args)) + end + end + end +end + +namespace :policy_factory do + def api_key + return ENV['CONJUR_AUTHN_API_KEY'] if ENV.key?('CONJUR_AUTHN_API_KEY') + + raise 'Conjur `admin` user API key must be provided via `CONJUR_AUTHN_API_KEY` environment variable' + end + + def client + @client ||= begin + Conjur.configuration.account = 'cucumber' + Conjur.configuration.appliance_url = 'http://localhost:3000/' + Conjur::API.new_from_key('admin', api_key) + end + end + + task test: :environment do + binding.pry + # tester = Factories::Templates::ValidateTemplate.new + # tester.test( + # factory: Factories::Templates::Core::Group, + # template_params: { "id"=>"test-group", "branch"=>"root", "annotations"=>{ "one"=>1, "two"=>2, "test/three"=>3 } } + # ) + # tester.test( + # factory: Factories::Templates::Core::Group, + # template_params: { "id"=>"test-group", "branch"=>"root" } + # ) + end + + task load: :environment do + binding.pry + client.load_policy('root', Factories::Templates::Base::V1::BasePolicy.policy) + client.resource('cucumber:variable:conjur/factories/core/v1/group').add_value(Factories::Templates::Core::V1::Group.data) + client.resource('cucumber:variable:conjur/factories/core/v1/managed-policy').add_value(Factories::Templates::Core::V1::ManagedPolicy.data) + client.resource('cucumber:variable:conjur/factories/core/v1/policy').add_value(Factories::Templates::Core::V1::Policy.data) + client.resource('cucumber:variable:conjur/factories/core/v1/user').add_value(Factories::Templates::Core::V1::User.data) + client.resource('cucumber:variable:conjur/factories/authenticators/v1/authn-oidc').add_value(Factories::Templates::Authenticators::V1::AuthnOidc.data) + client.resource('cucumber:variable:conjur/factories/connections/v1/database').add_value(Factories::Templates::Connections::V1::Database.data) + end + + task retrieve_auth_token: :environment do + url = 'http://localhost:3000/' + username = 'admin' + + response = RestClient.post("#{url}/authn/cucumber/#{username}/authenticate", api_key, 'Accept-Encoding' => 'base64') + puts response.body + end +end diff --git a/spec/app/db/repository/policy_factory_repository_spec.rb b/spec/app/db/repository/policy_factory_repository_spec.rb new file mode 100644 index 0000000000..df89cf4be5 --- /dev/null +++ b/spec/app/db/repository/policy_factory_repository_spec.rb @@ -0,0 +1,421 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(DB::Repository::PolicyFactoryRepository) do + # Ensure the variables below have not been set in previous tests + before(:all) do + ::Resource['rspec:variable:conjur/factories/core/v1/group']&.destroy + ::Resource['rspec:variable:conjur/factories/core/v1/user']&.destroy + end + subject { DB::Repository::PolicyFactoryRepository.new } + + describe 'find_all' do + context 'when no factories exist' do + before(:each) do + ::Role.create(role_id: 'rspec:group:conjur/policy-factory-users') + end + after(:each) do + ::Role['rspec:group:conjur/policy-factory-users'].destroy + end + it 'returns an error' do + response = subject.find_all( + account: 'foo-bar', + role: ::Role['rspec:group:conjur/policy-factory-users'] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to eq('Role does not have permission to use Factories') + end + end + + context 'when factories exist' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:owner_id) { role_id } + let(:factory1) { 'rspec:variable:conjur/factories/core/v1/group' } + let(:factory2) { 'rspec:variable:conjur/factories/core/v1/user' } + + before(:each) do + ::Role.create(role_id: role_id) + end + after(:each) do + ::Role[role_id].destroy + end + + context 'when role does not have execute permission on any factories' do + let(:owner_id) { 'rspec:group:admin' } + before(:each) do + ::Role.create(role_id: owner_id) + ::Resource.create(resource_id: factory1, owner_id: owner_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: owner_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + ::Role[owner_id].destroy + end + it 'returns an error' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to eq('Role does not have permission to use Factories') + end + end + context 'when role has execute permission on some factories' do + let(:owner_id) { 'rspec:group:admin' } + before(:each) do + ::Role.create(role_id: owner_id) + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: owner_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + ::Role[owner_id].destroy + end + it 'returns permitted factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.name).to eq('group') + expect(response.result.first.description).to eq('Creates a Conjur Group') + end + end + context 'when role has execute permission on all factories' do + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: role_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'returns all factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(2) + expect(response.result.map(&:name)).to include('group') + expect(response.result.map(&:name)).to include('user') + end + end + context 'when multiple versions of a factory exist' do + let(:factory1) { 'rspec:variable:conjur/factories/core/v1/group' } + let(:factory2) { 'rspec:variable:conjur/factories/core/v2/group' } + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: role_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::Group.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'returns the latest version' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.version).to eq('v2') + end + context 'when there are more than 10 factory versions' do + let(:factory1) { 'rspec:variable:conjur/factories/core/v9/group' } + let(:factory2) { 'rspec:variable:conjur/factories/core/v10/group' } + it 'returns the latest version' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.version).to eq('v10') + end + end + end + context 'when some factories are empty' do + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Resource.create(resource_id: factory2, owner_id: role_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'does not return empty factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.name).to eq('user') + end + end + context 'when all factories are empty' do + # TODO: this error is a bit weird... I'd expect a specific error if no factories were configured. + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Resource.create(resource_id: factory2, owner_id: role_id) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'does not return any factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to eq('Role does not have permission to use Factories') + end + end + end + end + + describe '.find' do + context 'when factory does not exist' do + before(:each) do + ::Role.create(role_id: 'rspec:group:conjur/policy-factory-users') + end + after(:each) do + ::Role['rspec:group:conjur/policy-factory-users'].destroy + end + it 'returns an error' do + response = subject.find( + kind: 'foo', + id: 'bar', + account: 'foo-bar', + role: ::Role['rspec:group:conjur/policy-factory-users'] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:not_found) + expect(response.message).to include( + { + resource: 'foo/v1/bar', + message: 'Requested Policy Factory does not exist' + } + ) + end + end + context 'when factory exists' do + context 'when requesting role does not have permission' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:resource_id) { 'rspec:variable:conjur/factories/core/v1/group' } + before(:each) do + ::Role.create(role_id: role_id) + admin = ::Role.create(role_id: 'rspec:user:policy_admin') + ::Resource.create(resource_id: resource_id, owner: admin) + end + after(:each) do + ::Role[role_id].destroy + ::Resource[resource_id].destroy + ::Role['rspec:user:policy_admin'].destroy + end + it 'returns an error' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to include( + { + resource: 'core/v1/group', + message: 'Requested Policy Factory is not available' + } + ) + end + end + context 'when factory is empty' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:resource_id) { 'rspec:variable:conjur/factories/core/v1/group' } + before(:each) do + ::Role.create(role_id: role_id) + ::Resource.create(resource_id: resource_id, owner_id: role_id) + end + after(:each) do + ::Resource[resource_id].destroy + ::Role[role_id].destroy + end + it 'returns an error' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:bad_request) + expect(response.message).to include( + { + resource: 'core/v1/group', + message: 'Requested Policy Factory is not available' + } + ) + end + end + context 'requesting role has permission' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:resource_id) { 'rspec:variable:conjur/factories/core/v1/group' } + before(:each) do + ::Role.create(role_id: role_id) + ::Resource.create(resource_id: resource_id, owner_id: role_id) + ::Secret.create( + resource_id: resource_id, + value: Factories::Templates::Core::V1::Group.data + ) + end + after(:each) do + ::Resource[resource_id].destroy + ::Role[role_id].destroy + end + it 'returns the policy factory' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.class).to eq(DB::Repository::DataObjects::PolicyFactory) + expect(response.result.name).to eq('group') + end + context 'when description attribute is missing' do + before(:each) do + data = Factories::Templates::Core::V1::Group.data + decoded_data = JSON.parse(Base64.decode64(data)) + decoded_data['schema'].delete('description') + + ::Secret.create( + resource_id: resource_id, + value: Base64.encode64(decoded_data.to_json) + ) + end + it 'includes an empty description' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.description).to eq('') + end + end + end + context 'when multiple versions exist' do + let(:owner_id) { 'rspec:group:conjur/policy-factory-users' } + let(:version1) { 'rspec:variable:conjur/factories/core/v1/group' } + let(:version2) { 'rspec:variable:conjur/factories/core/v2/group' } + before(:each) do + ::Role.create(role_id: owner_id) + ::Resource.create(resource_id: version1, owner_id: owner_id) + ::Secret.create( + resource_id: version1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: version2, owner_id: owner_id) + ::Secret.create( + resource_id: version2, + value: Factories::Templates::Core::V1::Group.data + ) + end + after(:each) do + ::Resource[version1].destroy + ::Resource[version2].destroy + ::Role[owner_id].destroy + end + context 'when no version is provided' do + it 'returns the latest version' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[owner_id] + ) + expect(response.success?).to eq(true) + expect(response.result.version).to eq('v2') + end + end + context 'when a version is provided' do + it 'returns the requested version' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[owner_id], + version: 'v1' + ) + expect(response.success?).to eq(true) + expect(response.result.version).to eq('v1') + end + end + + context 'when there are more than 10 factory versions' do + let(:version1) { 'rspec:variable:conjur/factories/core/v9/group' } + let(:version2) { 'rspec:variable:conjur/factories/core/v10/group' } + it 'returns the latest version' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[owner_id] + ) + expect(response.success?).to eq(true) + expect(response.result.version).to eq('v10') + end + end + + end + end + end +end diff --git a/spec/app/domain/factories/create_from_policy_factory_spec.rb b/spec/app/domain/factories/create_from_policy_factory_spec.rb new file mode 100644 index 0000000000..f2ac6bd029 --- /dev/null +++ b/spec/app/domain/factories/create_from_policy_factory_spec.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Factories::CreateFromPolicyFactory) do + let(:rest_client) { spy(RestClient) } + subject do + Factories::CreateFromPolicyFactory + .new(http: rest_client) + .call( + factory_template: factory_template, + request_body: request, + account: 'rspec', + authorization: 'foo-bar' + ) + end + + describe('.call') do + context 'when using a simple factory' do + let(:factory_template) do + decoded_factory = JSON.parse(Base64.decode64(Factories::Templates::Core::V1::User.data)) + DB::Repository::DataObjects::PolicyFactory.new( + schema: decoded_factory['schema'], + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'] + ) + end + context 'when request is invalid' do + context 'when request body is missing' do + let(:request) { nil } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq('Request body must be JSON') + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is empty' do + let(:request) { '' } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq('Request body must be JSON') + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is malformed JSON' do + let(:request) { '{"foo": "bar }' } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq('Request body must be valid JSON') + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is missing keys' do + let(:request) { { id: 'foo' }.to_json } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "A value is required for 'branch'", key: 'branch' }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is missing values' do + let(:request) { { id: '', branch: 'foo' }.to_json } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "A value is required for 'id'", key: 'id' }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when the request body includes invalid values' do + let(:request) { { id: 'foo%', branch: 'b@r' }.to_json } + it 'submits the expected policy to Conjur with invalid characters removed' do + expect(subject.success?).to be(true) + expect(rest_client).to have_received(:post).with('http://localhost:3000/policies/rspec/policy/br', "- !user\n id: foo\n annotations:\n factory: core/v1/user\n", { 'Authorization' => 'foo-bar' }) + end + end + context 'when request body is valid' do + let(:request) { { id: 'foo', branch: 'bar' }.to_json } + it 'submits the expected policy to Conjur' do + expect(subject.success?).to be(true) + expect(rest_client).to have_received(:post).with('http://localhost:3000/policies/rspec/policy/bar', "- !user\n id: foo\n annotations:\n factory: core/v1/user\n", { 'Authorization' => 'foo-bar' }) + end + context 'when inputs include a hash (ex. for annotations)' do + let(:request) { { id: 'foo', branch: 'bar', annotations: { 'foo' => 'bar', 'bing' => 'bang' } }.to_json } + it 'submits the expected policy to Conjur' do + expect(subject.success?).to be(true) + expect(rest_client).to have_received(:post).with('http://localhost:3000/policies/rspec/policy/bar', "- !user\n id: foo\n annotations:\n factory: core/v1/user\n foo: bar\n bing: bang\n", { 'Authorization' => 'foo-bar' }) + end + end + context 'when the Conjur API returns an error' do + context 'when credentials are invalid' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 401, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq('Authentication failed') + expect(subject.status).to eq(:unauthorized) + end + end + context 'when role is not permitted to apply the policy' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 403, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to include({ + message: "Applying generated policy to 'bar' is not allowed", + request_error: 'foo' + }) + expect(subject.status).to eq(:forbidden) + end + end + context 'when policy refers to invalid roles or resources' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 404, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq("Unable to apply generated policy to 'bar'") + expect(subject.status).to eq(:not_found) + end + end + context 'when policy load is currently in progress' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 409, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq("Failed to apply generated policy to 'bar'") + expect(subject.status).to eq(:bad_request) + end + end + context 'when a connection timeout error occurs' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::ServerBrokeConnection.new + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq("Failed to apply generated policy to 'bar'") + expect(subject.status).to eq(:bad_request) + end + end + end + end + end + end + context 'when using a complex factory' do + let(:factory_template) do + decoded_factory = JSON.parse(Base64.decode64(Factories::Templates::Connections::V1::Database.data)) + DB::Repository::DataObjects::PolicyFactory.new( + schema: decoded_factory['schema'], + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'] + ) + end + let(:request) { { id: 'bar', branch: 'foo', variables: variables }.to_json } + context 'when request body is missing values' do + let(:variables) { { port: '1234', url: 'http://localhost', username: 'super-user' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "A value is required for '/variables/password'", key: '/variables/password' }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when variable value is not a string' do + context 'when value is an integer' do + let(:variables) { { port: 1234, url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "Validation error: '/variables/port' must be a string" }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when value is a boolean' do + let(:variables) { { port: true, url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "Validation error: '/variables/port' must be a string" }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when value is null' do + let(:variables) { { port: nil, url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "Validation error: '/variables/port' must be a string" }]) + expect(subject.status).to eq(:bad_request) + end + end + end + context 'when request body includes required values' do + let(:variables) { { port: '1234', url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + variables.each do |variable, value| + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2F#{variable}", + value, + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '') + ) + end + expect(subject.success?).to be(true) + end + end + context 'when request body includes extra variable values' do + let(:variables) { { foo: 'bar', port: '1234', url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + # let(:request) { { id: 'bar', branch: 'foo', variables: variables }.to_json } + it 'only saves variables defined in the factory' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + variables.delete(:foo) + variables.each do |variable, value| + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2F#{variable}", + value, + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '') + ) + end + expect(subject.success?).to be(true) + end + end + context 'when role is not permitted to set variables' do + let(:variables) { { port: '1234', url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + context 'when role is not authorized' do + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 401, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Role is unauthorized to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'") + expect(subject.status).to eq(:unauthorized) + end + end + context 'when role lacks required privileges' do + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 403, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Role lacks the privilege to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'") + expect(subject.status).to eq(:forbidden) + end + end + context 'when variable is missing' do + it 'fails with an appropriate error' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 404, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Failed to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'. Status Code: '404', Response: ''") + expect(subject.status).to eq(:bad_request) + end + end + context 'when there is a variable missing' do + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 404, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Failed to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'. Status Code: '404', Response: ''") + expect(subject.status).to eq(:bad_request) + end + end + context 'when there is a timeout attempting to set the secret' do + it 'returns the appropriate error' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::ServerBrokeConnection.new + ) + + expect(subject.success?).to be(false) + expect(subject.message).to include({ + message: "Failed set variable 'secrets/rspec/variable/foo%2Fbar%2Furl'", + request_error: 'Server broke connection' + }) + expect(subject.status).to eq(:bad_request) + end + end + end + end + end +end diff --git a/spec/app/domain/factories/renderer_spec.rb b/spec/app/domain/factories/renderer_spec.rb new file mode 100644 index 0000000000..810f1d906b --- /dev/null +++ b/spec/app/domain/factories/renderer_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Factories::Renderer) do + subject { Factories::Renderer.new } + describe '.render' do + context 'when template is valid' do + let(:template) do + <<~TEMPLATE + - !policy + id: <%= id %> + TEMPLATE + end + context 'when all variables are present' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: 'foo' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: foo\n") + end + end + context 'when variables are missing' do + it 'returns an error' do + response = subject.render(template: template, variables: {}) + expect(response.success?).to be_falsey + expect(response.message).to eq("Required template variable 'id' is missing") + end + end + context 'when variable is nil' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: nil }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: \n") + end + end + context 'when extra variables are present' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: 'foo', bar: 'baz' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: foo\n") + end + end + context 'when variables are not strings' do + context 'when variable is an integer' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: 1 }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: 1\n") + end + end + context 'when variable is a boolean' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: false }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: false\n") + end + end + context 'when variable is an array' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: %w[foo bar] }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: [\"foo\", \"bar\"]\n") + end + end + end + end + context 'when template is invalid' do + context 'when there is not ERB closing tag' do + let(:template) do + <<~TEMPLATE + - !policy + id: <%= id + bar: baz + TEMPLATE + end + it 'the result is successful, does not perform substitution, and does not include the opening tag' do + response = subject.render(template: template, variables: { id: 'foo' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: id\n bar: baz\n") + end + end + context 'when the template is missing an ERB opening tag' do + let(:template) do + <<~TEMPLATE + - !policy + id: id %> + bar: baz + TEMPLATE + end + it 'the result is successful, does not perform substitution, and includes the closing tag' do + response = subject.render(template: template, variables: { id: 'foo' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: id %>\n bar: baz\n") + end + end + end + end +end diff --git a/spec/app/domain/responses_spec.rb b/spec/app/domain/responses_spec.rb new file mode 100644 index 0000000000..f3ad1b35db --- /dev/null +++ b/spec/app/domain/responses_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SuccessResponse do + context 'when initialized' do + let(:success) { SuccessResponse.new('foo') } + + describe '.result' do + it 'is the message set in the initializer' do + expect(success.result).to eq('foo') + end + end + + describe '.success?' do + it 'is true' do + expect(success.success?).to be(true) + end + end + + describe '.bind' do + it 'binds this response message to the next operation' do + expect(success.bind { |response| "#{response}-bar"}).to eq('foo-bar') + end + end + end +end + +describe FailureResponse do + context 'when initialized with only a message' do + let(:failure) { FailureResponse.new('bar') } + + describe '.message' do + it 'is the message set in the initializer' do + expect(failure.message).to eq('bar') + end + end + + describe '.level' do + it 'is at `warn` level by default' do + expect(failure.level).to eq(:warn) + end + end + + describe '.success?' do + it 'is false' do + expect(failure.success?).to be(false) + end + end + + describe '.bind' do + it "doesn't bind the response message to the next operation" do + expect(failure.bind { |response| "foo-#{response}"}).to eq(failure) + end + end + end + + context 'when initialized with all options' do + let(:message) { 'baz' } + let(:initialize_arguments) { { level: :debug, status: :forbidden } } + let(:failure) { FailureResponse.new(message, **initialize_arguments) } + + describe '.message' do + context 'when message is set in the initializer' do + context 'when it is a string' do + it "is returned as a string" do + expect(failure.message).to eq('baz') + end + end + context 'when it is a hash' do + let(:message) { { foo: 'baz' } } + it 'is returned as a hash' do + expect(failure.message).to eq({ foo: 'baz' }) + end + end + context 'when it is an array' do + let(:message) { [{ foo: 'baz' }] } + it 'is returned as an array' do + expect(failure.message).to eq([{ foo: 'baz' }]) + end + end + end + end + + describe '.to_s' do + context 'when message is a string' do + let(:message) { 'baz' } + it 'returns the expected string' do + expect(failure.to_s).to eq('baz') + end + end + context 'when message is a hash' do + let(:message) { { foo: 'baz' } } + it 'returns the expected string' do + expect(failure.to_s).to eq('{:foo=>"baz"}') + end + end + context 'when message is an array' do + let(:message) { ['baz'] } + it 'returns the expected string' do + expect(failure.to_s).to eq('["baz"]') + end + end + end + + describe '.level' do + context 'when level is a symbol' do + let(:initialize_arguments) { { level: :warn, status: :forbidden } } + it 'is the level set in the initializer' do + expect(failure.level).to eq(:warn) + end + end + + context 'when level is a string' do + let(:initialize_arguments) { { level: 'warn', status: :forbidden } } + it 'is the level set in the initializer' do + expect(failure.level).to eq(:warn) + end + end + end + + describe '.status' do + context 'when set in initializer' do + it 'is the message set in the initializer' do + expect(failure.status).to eq(:forbidden) + end + end + context 'when set by default' do + let(:initialize_arguments) { {} } + it 'is the default option' do + expect(failure.status).to eq(:unauthorized) + end + end + end + + describe '.success?' do + it 'is false' do + expect(failure.success?).to be(false) + end + end + end +end diff --git a/spec/app/presenters/policy_factories/error_spec.rb b/spec/app/presenters/policy_factories/error_spec.rb new file mode 100644 index 0000000000..8c4f4a27b5 --- /dev/null +++ b/spec/app/presenters/policy_factories/error_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Presenter::PolicyFactories::Error) do + subject do + Presenter::PolicyFactories::Error.new(response: response) + end + + describe '.present' do + context 'when response message is a string' do + let(:response) { FailureResponse.new('foo-bar') } + it 'returns the expected value' do + expect(subject.present).to eq({ + code: 401, + error: { message: 'foo-bar' } + }) + end + end + context 'when response message is a hash' do + let(:response) { FailureResponse.new({ message: 'foo-bar' }) } + it 'returns the expected value' do + expect(subject.present).to eq({ + code: 401, + error: { message: 'foo-bar' } + }) + end + end + context 'when response message is an array' do + let(:response) { FailureResponse.new([{ message: 'foo-bar' }]) } + it 'returns the expected value' do + expect(subject.present).to eq({ + code: 401, + error: [{ message: 'foo-bar' }] + }) + end + end + + end +end diff --git a/spec/app/presenters/policy_factories/index_spec.rb b/spec/app/presenters/policy_factories/index_spec.rb new file mode 100644 index 0000000000..32e1e23d60 --- /dev/null +++ b/spec/app/presenters/policy_factories/index_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Presenter::PolicyFactories::Index) do + describe '.present' do + subject do + Presenter::PolicyFactories::Index.new( + factories: [ + DB::Repository::DataObjects::PolicyFactory.new( + name: 'foo1', + classification: 'foo', + version: 'v1', + description: 'This is foo' + ), + DB::Repository::DataObjects::PolicyFactory.new( + name: 'bar1', + classification: 'foo', + version: 'v1' + ) + ] + ) + end + + it 'returns the expected hash' do + expect(subject.present).to include( + { + "foo" => [ + { + name: 'bar1', + namespace: 'foo', + 'full-name': 'foo/bar1', + 'current-version': 'v1', + description: '' + }, { + name: 'foo1', + namespace: 'foo', + 'full-name': 'foo/foo1', + 'current-version': 'v1', + description: 'This is foo' + } + ] + } + ) + end + end +end diff --git a/spec/app/presenters/policy_factories/show_spec.rb b/spec/app/presenters/policy_factories/show_spec.rb new file mode 100644 index 0000000000..ca93784abe --- /dev/null +++ b/spec/app/presenters/policy_factories/show_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Presenter::PolicyFactories::Show) do + describe '.present' do + subject { Presenter::PolicyFactories::Show.new(factory: factory) } + context 'when factory is composed of string keys' do + let(:factory) do + DB::Repository::DataObjects::PolicyFactory.new( + schema: { + 'title' => 'foo-bar', + 'description' => 'some factory', + 'properties' => { + 'id' => { + 'description' => 'Group ID', + 'type' => 'string' + }, + 'branch' => { + 'description' => 'Policy branch to load this group into', + 'type' => 'string' + }, + 'annotations' => { + 'description' => 'Additional annotations to add to the group', + 'type' => 'object' + } + }, + 'required' => %w[id branch] + }, + version: 'v1' + ) + end + + it 'returns the expected hash' do + expect(subject.present).to include( + { + title: 'foo-bar', + version: 'v1', + description: 'some factory', + properties: { + 'annotations' => { + 'description' => 'Additional annotations to add to the group', + 'type' => 'object' + }, + 'branch' => { + 'description' => 'Policy branch to load this group into', + 'type' => 'string' + }, + 'id' => { + 'description' => 'Group ID', + 'type' => 'string' + } + }, + required: %w[id branch] + } + ) + end + end + end +end diff --git a/spec/controllers/policy_factories_controller_spec.rb b/spec/controllers/policy_factories_controller_spec.rb new file mode 100644 index 0000000000..07cd0c2eae --- /dev/null +++ b/spec/controllers/policy_factories_controller_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require 'spec_helper' + +DatabaseCleaner.strategy = :truncation + +describe PolicyFactoriesController, type: :request do + before(:all) do + init_slosilo_keys("rspec") + + admin_user = Role.find_or_create(role_id: 'rspec:user:admin') + post( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge({ 'RAW_POST_DATA' => Factories::Templates::Base::V1::BasePolicy.policy }) + ) + { + 'core/v1/group' => Factories::Templates::Core::V1::Group.data, + 'core/v1/user' => Factories::Templates::Core::V1::User.data + }.each do |factory, data| + post( + "/secrets/rspec/variable/conjur/factories/#{factory}", + env: token_auth_header(role: admin_user).merge({ 'RAW_POST_DATA' => data }) + ) + end + end + + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + + describe '#index' do + context 'when user has permission' do + context 'it shows available factories' do + it 'displays expected values' do + get( + '/factories/rspec', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('200') + expect(result['core'].length).to eq(2) + expect(result['core']).to include({ + 'name' => 'group', + 'namespace' => 'core', + 'full-name' => 'core/group', + 'current-version' => 'v1', + 'description' => 'Creates a Conjur Group' + }) + expect(result['core']).to include({ + 'name' => 'user', + 'namespace' => 'core', + 'full-name' => 'core/user', + 'current-version' => 'v1', + 'description' => 'Creates a Conjur User' + }) + end + end + end + context 'when role does not have permission' do + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:foo-bar') } + it 'returns an appropriate error response' do + get( + '/factories/rspec', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('403') + expect(result).to eq({ + 'code' => 403, + 'error' => { + 'message' => 'Role does not have permission to use Factories' + } + }) + end + end + end + + describe '#show' do + let(:desired_result) do + { + 'title' => 'User Template', + 'version' => 'v1', + 'description' => 'Creates a Conjur User', + 'properties' => { + 'id' => { + 'description' => 'User ID', + 'type' => 'string' + }, + 'branch' => { + 'description' => 'Policy branch to load this user into', + 'type' => 'string' + }, + 'owner_role' => { + 'description' => 'The Conjur Role that will own this user', + 'type' => 'string' + }, + 'owner_type' => { + 'description' => 'The resource type of the owner of this user', + 'type' => 'string' + }, + 'ip_range' => { + 'description' => 'Limits the network range the user is allowed to authenticate from', + 'type' => 'string' + }, + 'annotations' => { + 'description' => 'Additional annotations', + 'type' => 'object' + } + }, + 'required' => %w[id branch] + } + end + context 'when role has permission to access' do + context 'when version is included in request' do + it 'returns the expected response' do + get( + '/factories/rspec/core/v1/user', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('200') + expect(result).to eq(desired_result) + end + end + context 'when version is not present in request' do + it 'returns the latest version' do + get( + '/factories/rspec/core/user', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('200') + expect(result).to eq(desired_result) + end + end + end + context 'when factory does not exist' do + it 'returns the expected response' do + get( + '/factories/rspec/core/v1/fake-factory', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('404') + expect(result).to eq({ + 'code' => 404, + 'error' => { + 'message' => 'Requested Policy Factory does not exist', + 'resource' => 'core/v1/fake-factory' + } + }) + end + end + end + describe '#create' do + context 'when a factory exists' do + context 'when role has permission to create from the factory' do + let(:policy_creator) { instance_double(Factories::CreateFromPolicyFactory) } + let(:double_class) { class_double(Factories::CreateFromPolicyFactory).as_stubbed_const } + + before do + allow(double_class).to receive(:new).and_return(policy_creator) + allow(policy_creator).to receive(:call).and_return(::SuccessResponse.new('success!!')) + end + + it 'creates the desire resource' do + auth_headers = token_auth_header(role: current_user) + request_body = { + 'id': 'test-user-1', + 'branch': 'root' + }.to_json + post( + '/factories/rspec/core/user', + env: auth_headers.merge({ 'RAW_POST_DATA' => request_body }) + ) + + # We're really only checking that the Factories::CreateFromPolicyFactory.call method + # is called with expected arguements. We're testing this class separately. + decoded_factory = JSON.parse(Base64.decode64(Factories::Templates::Core::V1::User.data)) + expect(policy_creator).to have_received(:call).with({ + account: 'rspec', + factory_template: DB::Repository::DataObjects::PolicyFactory.new( + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'], + schema: decoded_factory['schema'], + version: 'v1', + name: 'user', + classification: 'core', + description: decoded_factory['schema']&.dig('description').to_s + ), + request_body: { id: 'test-user-1', branch: 'root' }.to_json, + authorization: auth_headers['HTTP_AUTHORIZATION'] + }) + expect(response.code).to eq('200') + # This response is mocked. We're not really returning this in real life. + # Tests on Factories::CreateFromPolicyFactory verify that we always receive + # a success of failure object. + expect(response.body).to eq('success!!') + end + end + end + end +end From 3b0aefbb56fe45fcf72ea17e65d60b2549ba24cd Mon Sep 17 00:00:00 2001 From: Jason Vanderhoof Date: Fri, 14 Jul 2023 15:06:12 -0600 Subject: [PATCH 191/665] Initial set of Factory templates This commit includes an initial set of Factory templates. These may need some work before the official release. (cherry picked from commit b30637d469f70dff9d31bad317355ab82f50379e) --- .../templates/authenticators/v1/authn_oidc.rb | 112 ++++++++++++++++++ .../templates/base/v1/base_policy.rb | 50 ++++++++ .../templates/connections/v1/database.rb | 105 ++++++++++++++++ .../factories/templates/core/v1/grant.rb | 60 ++++++++++ .../factories/templates/core/v1/group.rb | 67 +++++++++++ .../templates/core/v1/managed_policy.rb | 58 +++++++++ .../factories/templates/core/v1/policy.rb | 68 +++++++++++ .../factories/templates/core/v1/user.rb | 74 ++++++++++++ lib/tasks/policy_factory.rake | 2 +- 9 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 app/domain/factories/templates/authenticators/v1/authn_oidc.rb create mode 100644 app/domain/factories/templates/base/v1/base_policy.rb create mode 100644 app/domain/factories/templates/connections/v1/database.rb create mode 100644 app/domain/factories/templates/core/v1/grant.rb create mode 100644 app/domain/factories/templates/core/v1/group.rb create mode 100644 app/domain/factories/templates/core/v1/managed_policy.rb create mode 100644 app/domain/factories/templates/core/v1/policy.rb create mode 100644 app/domain/factories/templates/core/v1/user.rb diff --git a/app/domain/factories/templates/authenticators/v1/authn_oidc.rb b/app/domain/factories/templates/authenticators/v1/authn_oidc.rb new file mode 100644 index 0000000000..f71f3460e0 --- /dev/null +++ b/app/domain/factories/templates/authenticators/v1/authn_oidc.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Authenticators + module V1 + class AuthnOidc + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: authenticators/v1/authn-oidc + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + + body: + - !webservice + + - !variable provider-uri + - !variable client-id + - !variable client-secret + - !variable redirect-uri + - !variable claim-mapping + + - !group + id: authenticatable + annotations: + description: Group with permission to authenticate using this authenticator + + - !permit + role: !group authenticatable + privilege: [ read, authenticate ] + resource: !webservice + + - !webservice + id: status + annotations: + description: Web service for checking authenticator status + + - !group + id: operators + annotations: + description: Group with permission to check the authenticator status + + - !permit + role: !group operators + privilege: [ read ] + resource: !webservice status + TEMPLATE + end + + def data + Base64.encode64({ + version: 'v1', + policy: Base64.encode64(policy_template), + policy_branch: "conjur/authn-oidc", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Authn-OIDC Template", + "description": "Create a new Authn-OIDC Authenticator", + "type": "object", + "properties": { + "id": { + "description": "Service ID of the Authenticator", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + }, + "variables": { + "type": "object", + "properties": { + "provider-uri": { + "description": "OIDC Provider endpoint", + "type": "string" + }, + "client-id": { + "description": "OIDC Client ID", + "type": "string" + }, + "client-secret": { + "description": "OIDC Client Secret", + "type": "string" + }, + "redirect-uri": { + "description": "Target URL to redirect to after successful authentication", + "type": "string" + }, + "claim-mapping": { + "description": "OIDC JWT claim mapping. This value must match to a Conjur Host ID.", + "type": "string" + } + }, + "required": %w[provider-uri client-id client-secret claim-mapping] + } + }, + "required": %w[id variables] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/base/v1/base_policy.rb b/app/domain/factories/templates/base/v1/base_policy.rb new file mode 100644 index 0000000000..f114963d2f --- /dev/null +++ b/app/domain/factories/templates/base/v1/base_policy.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Factories + module Templates + module Base + module V1 + class BasePolicy + class << self + def policy + <<~TEMPLATE + - !policy + id: conjur + body: + - !policy + id: factories + body: + - !policy + id: core + annotations: + description: "Create Conjur primatives and manage permissions" + body: + - !variable v1/grant + - !variable v1/group + - !variable v1/host + - !variable v1/layer + - !variable v1/managed-policy + - !variable v1/policy + - !variable v1/user + + - !policy + id: authenticators + annotations: + description: "Generate new Authenticators" + body: + - !variable v1/authn-oidc + - !policy + id: connections + annotations: + description: "Create connections to external services" + body: + - !variable v1/database + - !variable v2/database + TEMPLATE + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/connections/v1/database.rb b/app/domain/factories/templates/connections/v1/database.rb new file mode 100644 index 0000000000..6a7b01ab02 --- /dev/null +++ b/app/domain/factories/templates/connections/v1/database.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Connections + module V1 + class Database + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: connections/v1/database + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + + body: + - &variables + - !variable url + - !variable port + - !variable username + - !variable password + + - !group consumers + - !group administrators + + # consumers can read and execute + - !permit + resource: *variables + privileges: [ read, execute ] + role: !group consumers + + # administrators can update (and read and execute, via role grant) + - !permit + resource: *variables + privileges: [ update ] + role: !group administrators + + # administrators has role consumers + - !grant + member: !group administrators + role: !group consumers + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Database Connection Template", + "description": "All information for connecting to a database", + "type": "object", + "properties": { + "id": { + "description": "Database Connection Identifier", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this connection into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + }, + "variables": { + "type": "object", + "properties": { + "url": { + "description": "Database URL", + "type": "string" + }, + "port": { + "description": "Database Port", + "type": "string" + }, + "username": { + "description": "Database Username", + "type": "string" + }, + "password": { + "description": "Database Password", + "type": "string" + }, + }, + "required": %w[url port username password] + } + }, + "required": %w[id branch variables] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/grant.rb b/app/domain/factories/templates/core/v1/grant.rb new file mode 100644 index 0000000000..6fdc2613d5 --- /dev/null +++ b/app/domain/factories/templates/core/v1/grant.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Grant + class << self + def policy_template + <<~TEMPLATE + - !grant + member: !<%= member_resource_type %> <%= member_resource_id %> + role: !<%= role_resource_type %> <%= role_resource_id %> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Grant Template", + "description": "Assigns a Role to another Role", + "type": "object", + "properties": { + "branch": { + "description": "Policy branch to load this grant into", + "type": "string" + }, + "member_resource_type": { + "description": "The member type (group, host, user, etc.) for the grant", + "type": "string" + }, + "member_resource_id": { + "description": "The member resource identifier for the grant", + "type": "string" + }, + "role_resource_type": { + "description": "The role type (group, host, user, etc.) for the grant", + "type": "string" + }, + "role_resource_id": { + "description": "The role resource identifier for the grant", + "type": "string" + } + }, + "required": %w[branch member_resource_type member_resource_id role_resource_type role_resource_id] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/group.rb b/app/domain/factories/templates/core/v1/group.rb new file mode 100644 index 0000000000..c299b9e356 --- /dev/null +++ b/app/domain/factories/templates/core/v1/group.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Group + class << self + def policy_template + <<~TEMPLATE + - !group + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + annotations: + factory: core/v1/group + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Group Template", + "description": "Creates a Conjur Group", + "type": "object", + "properties": { + "id": { + "description": "Group Identifier", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this group into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this group", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this group", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/managed_policy.rb b/app/domain/factories/templates/core/v1/managed_policy.rb new file mode 100644 index 0000000000..84095c7f35 --- /dev/null +++ b/app/domain/factories/templates/core/v1/managed_policy.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class ManagedPolicy + class << self + def policy_template + <<~TEMPLATE + - !group <%= name %>-admins + - !policy + id: <%= name %> + owner: !group <%= name %>-admins + annotations: + factory: core/v1/managed-policy + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Managed Policy Template", + "description": "Policy with an owner group", + "type": "object", + "properties": { + "name": { + "description": "Policy name (used to create the policy ID and the -admins owner group)", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this policy into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[name branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/policy.rb b/app/domain/factories/templates/core/v1/policy.rb new file mode 100644 index 0000000000..a5d8aad9a3 --- /dev/null +++ b/app/domain/factories/templates/core/v1/policy.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Policy + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + annotations: + factory: core/v1/policy + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "User Template", + "description": "Creates a Conjur Policy", + "type": "object", + "properties": { + "id": { + "description": "Policy ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this policy into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this policy", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this policy", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/user.rb b/app/domain/factories/templates/core/v1/user.rb new file mode 100644 index 0000000000..c293a30d70 --- /dev/null +++ b/app/domain/factories/templates/core/v1/user.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class User + class << self + def policy_template + <<~TEMPLATE + - !user + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + <% if defined?(ip_range) -%> + restricted_to: <%= ip_range %> + <% end -%> + annotations: + factory: core/v1/user + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "User Template", + "description": "Creates a Conjur User", + "type": "object", + "properties": { + "id": { + "description": "User ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this user into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this user", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this user", + "type": "string" + }, + "ip_range": { + "description": "Limits the network range the user is allowed to authenticate from", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/lib/tasks/policy_factory.rake b/lib/tasks/policy_factory.rake index 2f726cb7d2..a40b266e28 100644 --- a/lib/tasks/policy_factory.rake +++ b/lib/tasks/policy_factory.rake @@ -1,6 +1,6 @@ # frozen_string_literal: true -module Factory +module Factories module Templates class ValidateTemplate def initialize(renderer: Factories::RenderPolicy.new) From 80c3cc0bba70c62ca52ead2d243d013f7a0fc2c5 Mon Sep 17 00:00:00 2001 From: egvili Date: Sun, 13 Aug 2023 09:15:42 +0300 Subject: [PATCH 192/665] Fix CONJSE-1785 (cherry picked from commit c6be31339cc93e6faccbcacfc3b02bb732add42c) --- CHANGELOG.md | 9 ++++ cucumber/policy/features/deletion.feature | 59 ----------------------- 2 files changed, 9 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ceb74737..69d531c075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [1.20.0] - 2023-07-11 +### Security +- Support plural syntax for revoke and deny + [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) +- Previously, attempting to add and remove a privilege in the same policy load + resulted in only the positive privilege (grant, permit) taking effect. Now we + fail safe and the negative privilege statement (revoke, deny) is the final + outcome + [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) + ### Added - Telemetry support [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) diff --git a/cucumber/policy/features/deletion.feature b/cucumber/policy/features/deletion.feature index 818d8dd102..9f6ac3b285 100644 --- a/cucumber/policy/features/deletion.feature +++ b/cucumber/policy/features/deletion.feature @@ -309,62 +309,3 @@ Feature: Deleting objects and relationships. record: !variable to_be_deleted """ Then variable "to_be_deleted" does not exist - And I list the roles permitted to read variable "db/password" - Then the role list does not include host "host-01" - - @smoke - Scenario: The bulk !deny statement can be used to revoke a permission from roles and members. - Given I load a policy: - """ - - !variable db/address - - !variable db/username - - !variable db/password - - !host host-01 - - !host host-02 - - !host host-03 - - !permit - resources: - - !variable db/address - - !variable db/username - - !variable db/password - privileges: [ update ] - roles: - - !host host-01 - - !host host-02 - - !host host-03 - """ - And I list the roles permitted to update variable "db/address" - Then the role list includes host "host-01" - Then the role list includes host "host-02" - Then the role list includes host "host-03" - And I list the roles permitted to update variable "db/username" - Then the role list includes host "host-01" - Then the role list includes host "host-02" - Then the role list includes host "host-03" - And I list the roles permitted to update variable "db/password" - Then the role list includes host "host-01" - Then the role list includes host "host-02" - Then the role list includes host "host-03" - And I update the policy with: - """ - - !deny - resources: - - !variable db/address - - !variable db/username - privileges: [ update ] - roles: - - !host host-01 - - !host host-02 - """ - When I list the roles permitted to update variable "db/address" - Then the role list does not include host "host-01" - And the role list does not include host "host-02" - And the role list includes host "host-03" - When I list the roles permitted to update variable "db/username" - Then the role list does not include host "host-01" - And the role list does not include host "host-02" - And the role list includes host "host-03" - When I list the roles permitted to update variable "db/password" - Then the role list includes host "host-01" - And the role list includes host "host-02" - And the role list includes host "host-03" From 8c2b6de1d05dd48c84c00c0bafde9d79e38355a1 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 16 Aug 2023 09:27:28 -0400 Subject: [PATCH 193/665] Cleanup: Fix changelog issues The last PR merges introduced two different unreleased versions (1.19.6 & 1.20.0). This consolidates the changes to the new next version 1.20.0. (cherry picked from commit 893699a2a96b6fcbcc49753d45d7138c7f11835e) --- CHANGELOG.md | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d531c075..8e47f148b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,29 +139,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [1.20.0] - 2023-07-11 -### Security -- Support plural syntax for revoke and deny - [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) -- Previously, attempting to add and remove a privilege in the same policy load - resulted in only the positive privilege (grant, permit) taking effect. Now we - fail safe and the negative privilege statement (revoke, deny) is the final - outcome - [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) - ### Added +- New flag to `conjurctl server` command called `--no-migrate` which allows for skipping + the database migration step when starting the server. + [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) - Telemetry support [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) - Introduces support for Policy Factory, which enables resource creation through a new `factories` API. [cyberark/conjur#2855](https://github.com/cyberark/conjur/pull/2855/files) -## [1.19.6] - 2023-07-05 - -### Added -- New flag to `conjurctl server` command called `--no-migrate` which allows for skipping - the database migration step when starting the server. - [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) - ### Changed - The database thread pool max connection size is now based on the number of web worker threads per process, rather than an arbitrary fixed number. This @@ -172,8 +159,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) + +### Security - Support plural syntax for revoke and deny [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) +- Previously, attempting to add and remove a privilege in the same policy load + resulted in only the positive privilege (grant, permit) taking effect. Now we + fail safe and the negative privilege statement (revoke, deny) is the final + outcome + [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) ## [1.19.5] - 2023-06-29 From 19437d33c884fa1d9f2da22619ba456b5519bdb4 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Fri, 18 Aug 2023 02:51:03 +0300 Subject: [PATCH 194/665] Upgrade OpenIDConnect dependency to fix environment proxy support (cherry picked from commit 384321fbff13c1f528f7b6b6727c84e99ef9b679) --- CHANGELOG.md | 8 +++- Gemfile | 2 +- Gemfile.lock | 46 ++++++++++++------- .../authentication/authn_oidc/v2/client.rb | 2 +- .../o_auth/discover_identity_provider.rb | 2 +- .../o_auth/discover_identity_provider_spec.rb | 2 +- .../authn-oidc/v2/identity/client_load.yml | 43 +++++++++-------- ...covery_endpoint-valid_oidc_credentials.yml | 43 +++++++++-------- 8 files changed, 87 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e47f148b8..008ccbe908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,7 +137,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Remove auto-release options to allow for a pseudo-fork development on a branch -## [1.20.0] - 2023-07-11 +## [1.20.0] - 2023-08-16 + +### Fixed +- OIDC authenticators support `https_proxy` and `HTTPS_PROXY` environment variables + [cyberark/conjur#2902](https://github.com/cyberark/conjur/pull/2902) +- Support plural syntax for revoke and deny + [cyberark/conjur#2901](https://github.com/cyberark/conjur/pull/2901) ### Added - New flag to `conjurctl server` command called `--no-migrate` which allows for skipping diff --git a/Gemfile b/Gemfile index cf65ea3726..a531576253 100644 --- a/Gemfile +++ b/Gemfile @@ -74,7 +74,7 @@ gem 'websocket' # authn-oidc, gcp, azure, jwt gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements # authn-oidc -gem 'openid_connect' +gem 'openid_connect', '~> 2.0' gem "anyway_config" gem 'i18n', '~> 1.8.11' diff --git a/Gemfile.lock b/Gemfile.lock index 9c6ec41544..c42420713b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,7 +112,7 @@ GEM base32-crockford (0.1.0) base58 (0.2.3) bcrypt (3.1.16) - bindata (2.4.10) + bindata (2.4.15) builder (3.2.4) byebug (11.1.3) childprocess (4.1.0) @@ -223,6 +223,12 @@ GEM event_emitter (0.2.6) eventmachine (1.2.7) excon (0.91.0) + faraday (2.7.10) + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-net_http (3.0.2) faye-websocket (0.11.1) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) @@ -251,7 +257,6 @@ GEM http-form_data (2.3.0) http-parser (1.2.3) ffi-compiler (>= 1.0, < 2.0) - httpclient (2.8.3) i18n (1.8.11) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -261,10 +266,12 @@ GEM activesupport (>= 4.2.0) multi_json (>= 1.2) jmespath (1.6.1) - json-jwt (1.13.0) + json-jwt (1.16.3) activesupport (>= 4.2) aes_key_wrap bindata + faraday (~> 2.0) + faraday-follow_redirects json_schemer (0.2.24) ecma-re-validator (~> 0.3) hana (~> 1.3) @@ -319,16 +326,19 @@ GEM racc (~> 1.4) nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) - openid_connect (1.3.0) + openid_connect (2.2.0) activemodel attr_required (>= 1.0.0) - json-jwt (>= 1.5.0) - rack-oauth2 (>= 1.6.1) - swd (>= 1.0.0) + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + net-smtp + rack-oauth2 (~> 2.2) + swd (~> 2.0) tzinfo validate_email validate_url - webfinger (>= 1.0.1) + webfinger (~> 2.0) parallel (1.21.0) parallel_tests (4.2.0) parallel @@ -351,10 +361,11 @@ GEM raabro (1.4.0) racc (1.7.1) rack (2.2.7) - rack-oauth2 (1.19.0) + rack-oauth2 (2.2.0) activesupport attr_required - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-rewrite (1.5.1) @@ -451,6 +462,7 @@ GEM rake (>= 0.8.1) ruby-next-core (0.14.0) ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) @@ -482,10 +494,11 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - swd (1.3.0) + swd (2.0.2) activesupport (>= 3) attr_required (>= 0.0.5) - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects sys-uname (1.2.2) ffi (~> 1.1) table_print (1.5.7) @@ -501,13 +514,14 @@ GEM validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) - validate_url (1.0.13) + validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix vcr (6.1.0) - webfinger (1.2.0) + webfinger (2.1.2) activesupport - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects webmock (3.14.0) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -566,7 +580,7 @@ DEPENDENCIES net-ldap net-ssh nokogiri (>= 1.8.2) - openid_connect + openid_connect (= 2.2.0) parallel parallel_tests pg diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb index 80a82c00c2..8f69559395 100644 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ b/app/domain/authentication/authn_oidc/v2/client.rb @@ -104,7 +104,7 @@ def discovery_information(invalidate: false) skip_nil: true ) do @discovery_configuration.discover!(@authenticator.provider_uri) - rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e + rescue Errno::ETIMEDOUT => e raise Errors::Authentication::OAuth::ProviderDiscoveryTimeout.new(@authenticator.provider_uri, e.message) rescue => e raise Errors::Authentication::OAuth::ProviderDiscoveryFailed.new(@authenticator.provider_uri, e.message) diff --git a/app/domain/authentication/o_auth/discover_identity_provider.rb b/app/domain/authentication/o_auth/discover_identity_provider.rb index aeb89794f9..cabd01ba64 100644 --- a/app/domain/authentication/o_auth/discover_identity_provider.rb +++ b/app/domain/authentication/o_auth/discover_identity_provider.rb @@ -33,7 +33,7 @@ def discover_provider LogMessages::Authentication::OAuth::IdentityProviderDiscoverySuccess.new ) @discovered_provider - rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e + rescue Errno::ETIMEDOUT => e raise_error(Errors::Authentication::OAuth::ProviderDiscoveryTimeout, e) rescue => e raise_error(Errors::Authentication::OAuth::ProviderDiscoveryFailed, e) diff --git a/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb b/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb index 05ba6c60b3..0ceb9f3a53 100644 --- a/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb +++ b/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb @@ -39,7 +39,7 @@ def mock_discovery_provider(error:) context "that fails on a timeout error" do subject do Authentication::OAuth::DiscoverIdentityProvider.new( - open_id_discovery_service: mock_discovery_provider(error: HTTPClient::ConnectTimeoutError) + open_id_discovery_service: mock_discovery_provider(error: Errno::ETIMEDOUT) ).call( provider_uri: test_provider_uri ) diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_load.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_load.yml index dce92df9da..7ab67211fc 100644 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_load.yml +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_load.yml @@ -4,42 +4,45 @@ http_interactions: method: get uri: https://redacted-host/redacted_app/.well-known/openid-configuration body: - encoding: UTF-8 + encoding: US-ASCII string: '' headers: User-Agent: - - SWD (1.3.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + - SWD 2.0.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: - "*/*" - Date: - - Mon, 10 Apr 2023 18:13:17 GMT response: status: code: 200 message: OK headers: + Content-Type: + - application/json; charset=utf-8 Date: - Mon, 10 Apr 2023 18:13:17 GMT Content-Length: - - '1347' + - '440' body: - encoding: UTF-8 + encoding: ASCII-8BIT string: "{\r\n \"authorization_endpoint\": \"https://redacted-host/OAuth2/Authorize/redacted_app\",\r\n - \ \"code_challenge_methods_supported\": [\r\n \"plain\",\r\n \"S256\"\r\n - \ ],\r\n \"jwks_uri\": \"https://redacted-host/OAuth2/Keys/redacted_app\",\r\n + \ \"issuer\": \"https://redacted-host/redacted_app/\",\r\n \"introspection_endpoint\": + \"https://redacted-host/OAuth2/Introspect/redacted_app\",\r\n \"userinfo_endpoint\": + \"https://redacted-host/OAuth2/UserInfo/redacted_app\",\r\n \"code_challenge_methods_supported\": + [\r\n \"plain\",\r\n \"S256\"\r\n ],\r\n \"end_session_endpoint\": + \"https://redacted-host/OAuth2/EndSessionV2/redacted_app\",\r\n \"claims_supported\": + [\r\n \"sub\",\r\n \"name\",\r\n \"family_name\",\r\n \"given_name\",\r\n + \ \"picture\",\r\n \"preferred_username\",\r\n \"email\",\r\n \"email_verified\",\r\n + \ \"phone_number\",\r\n \"phone_number_verified\",\r\n \"address\",\r\n + \ \"auth_time\",\r\n \"aud\",\r\n \"iss\",\r\n \"exp\",\r\n \"iat\"\r\n + \ ],\r\n \"id_token_signing_alg_values_supported\": [\r\n \"RS256\"\r\n + \ ],\r\n \"subject_types_supported\": [\r\n \"public\"\r\n ],\r\n \"scopes_supported\": + [\r\n \"openid\",\r\n \"profile\",\r\n \"email\",\r\n \"address\",\r\n + \ \"phone\"\r\n ],\r\n \"token_endpoint\": \"https://redacted-host/OAuth2/Token/redacted_app\",\r\n + \ \"jwks_uri\": \"https://redacted-host/OAuth2/Keys/redacted_app\",\r\n \ \"response_types_supported\": [\r\n \"code\",\r\n \"id_token\",\r\n \ \"id_token token\",\r\n \"code id_token\",\r\n \"code token\",\r\n - \ \"code id_token token\"\r\n ],\r\n \"introspection_endpoint\": \"https://redacted-host/OAuth2/Introspect/redacted_app\",\r\n - \ \"id_token_signing_alg_values_supported\": [\r\n \"RS256\"\r\n ],\r\n - \ \"subject_types_supported\": [\r\n \"public\"\r\n ],\r\n \"issuer\": - \"https://redacted-host/redacted_app/\",\r\n \"userinfo_endpoint\": - \"https://redacted-host/OAuth2/UserInfo/redacted_app\",\r\n \"token_endpoint\": - \"https://redacted-host/OAuth2/Token/redacted_app\",\r\n \"scopes_supported\": - [\r\n \"openid\",\r\n \"profile\",\r\n \"email\",\r\n \"address\",\r\n - \ \"phone\"\r\n ],\r\n \"claims_supported\": [\r\n \"sub\",\r\n \"name\",\r\n - \ \"family_name\",\r\n \"given_name\",\r\n \"picture\",\r\n \"preferred_username\",\r\n - \ \"email\",\r\n \"email_verified\",\r\n \"phone_number\",\r\n \"phone_number_verified\",\r\n - \ \"address\",\r\n \"auth_time\",\r\n \"aud\",\r\n \"iss\",\r\n - \ \"exp\",\r\n \"iat\"\r\n ],\r\n \"end_session_endpoint\": \"https://redacted-host/OAuth2/EndSessionV2/redacted_app\"\r\n}" + \ \"code id_token token\"\r\n ]\r\n}" recorded_at: Mon, 10 Apr 2023 18:13:17 GMT recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/discovery_endpoint-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/discovery_endpoint-valid_oidc_credentials.yml index bd889dabb1..05a364ee04 100644 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/discovery_endpoint-valid_oidc_credentials.yml +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/discovery_endpoint-valid_oidc_credentials.yml @@ -4,42 +4,45 @@ http_interactions: method: get uri: https://redacted-host/redacted_app/.well-known/openid-configuration body: - encoding: UTF-8 + encoding: US-ASCII string: '' headers: User-Agent: - - SWD (1.3.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + - SWD 2.0.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: - "*/*" - Date: - - Mon, 10 Apr 2023 18:16:56 GMT response: status: code: 200 message: OK headers: + Content-Type: + - application/json; charset=utf-8 Date: - Mon, 10 Apr 2023 18:16:56 GMT Content-Length: - - '1347' + - '440' body: - encoding: UTF-8 + encoding: ASCII-8BIT string: "{\r\n \"authorization_endpoint\": \"https://redacted-host/OAuth2/Authorize/redacted_app\",\r\n - \ \"code_challenge_methods_supported\": [\r\n \"plain\",\r\n \"S256\"\r\n - \ ],\r\n \"jwks_uri\": \"https://redacted-host/OAuth2/Keys/redacted_app\",\r\n + \ \"issuer\": \"https://redacted-host/redacted_app/\",\r\n \"introspection_endpoint\": + \"https://redacted-host/OAuth2/Introspect/redacted_app\",\r\n \"userinfo_endpoint\": + \"https://redacted-host/OAuth2/UserInfo/redacted_app\",\r\n \"code_challenge_methods_supported\": + [\r\n \"plain\",\r\n \"S256\"\r\n ],\r\n \"end_session_endpoint\": + \"https://redacted-host/OAuth2/EndSessionV2/redacted_app\",\r\n \"claims_supported\": + [\r\n \"sub\",\r\n \"name\",\r\n \"family_name\",\r\n \"given_name\",\r\n + \ \"picture\",\r\n \"preferred_username\",\r\n \"email\",\r\n \"email_verified\",\r\n + \ \"phone_number\",\r\n \"phone_number_verified\",\r\n \"address\",\r\n + \ \"auth_time\",\r\n \"aud\",\r\n \"iss\",\r\n \"exp\",\r\n \"iat\"\r\n + \ ],\r\n \"id_token_signing_alg_values_supported\": [\r\n \"RS256\"\r\n + \ ],\r\n \"subject_types_supported\": [\r\n \"public\"\r\n ],\r\n \"scopes_supported\": + [\r\n \"openid\",\r\n \"profile\",\r\n \"email\",\r\n \"address\",\r\n + \ \"phone\"\r\n ],\r\n \"token_endpoint\": \"https://redacted-host/OAuth2/Token/redacted_app\",\r\n + \ \"jwks_uri\": \"https://redacted-host/OAuth2/Keys/redacted_app\",\r\n \ \"response_types_supported\": [\r\n \"code\",\r\n \"id_token\",\r\n \ \"id_token token\",\r\n \"code id_token\",\r\n \"code token\",\r\n - \ \"code id_token token\"\r\n ],\r\n \"introspection_endpoint\": \"https://redacted-host/OAuth2/Introspect/redacted_app\",\r\n - \ \"id_token_signing_alg_values_supported\": [\r\n \"RS256\"\r\n ],\r\n - \ \"subject_types_supported\": [\r\n \"public\"\r\n ],\r\n \"issuer\": - \"https://redacted-host/redacted_app/\",\r\n \"userinfo_endpoint\": - \"https://redacted-host/OAuth2/UserInfo/redacted_app\",\r\n \"token_endpoint\": - \"https://redacted-host/OAuth2/Token/redacted_app\",\r\n \"scopes_supported\": - [\r\n \"openid\",\r\n \"profile\",\r\n \"email\",\r\n \"address\",\r\n - \ \"phone\"\r\n ],\r\n \"claims_supported\": [\r\n \"sub\",\r\n \"name\",\r\n - \ \"family_name\",\r\n \"given_name\",\r\n \"picture\",\r\n \"preferred_username\",\r\n - \ \"email\",\r\n \"email_verified\",\r\n \"phone_number\",\r\n \"phone_number_verified\",\r\n - \ \"address\",\r\n \"auth_time\",\r\n \"aud\",\r\n \"iss\",\r\n - \ \"exp\",\r\n \"iat\"\r\n ],\r\n \"end_session_endpoint\": \"https://redacted-host/OAuth2/EndSessionV2/redacted_app\"\r\n}" + \ \"code id_token token\"\r\n ]\r\n}" recorded_at: Mon, 10 Apr 2023 18:16:56 GMT recorded_with: VCR 6.1.0 From e16bd160440ac82d7808b40a48ffd84d4703b604 Mon Sep 17 00:00:00 2001 From: Jason Vanderhoof Date: Thu, 17 Aug 2023 16:15:33 -0600 Subject: [PATCH 195/665] Allows Policy Factories with variables to be set in the root policy This commit fixes a Policy Factory bug where variables for complex factories were not being successfully created in the root policy. Prior to this commit, the "root" policy was appended to the variable ids. This creates an invalid variable ID. (cherry picked from commit 91464585ec61bee67be46f0f487687aa9503c439) --- app/domain/factories/create_from_policy_factory.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/domain/factories/create_from_policy_factory.rb b/app/domain/factories/create_from_policy_factory.rb index 92ded89bde..d1e0fcdcda 100644 --- a/app/domain/factories/create_from_policy_factory.rb +++ b/app/domain/factories/create_from_policy_factory.rb @@ -65,7 +65,10 @@ def call(factory_template:, request_body:, account:, authorization:) return @success.new(result) unless factory_template.schema['properties'].key?('variables') # Set Policy Factory variables - @renderer.render(template: "#{factory_template.policy_branch}/<%= id %>", variables: template_variables) + variables_path = ["<%= id %>"] + # If the variables are headed for the "root" namespace, we don't want the namespace in the path + variables_path.prepend(factory_template.policy_branch) unless policy_load_path == 'root' + @renderer.render(template: variables_path.join('/'), variables: template_variables) .bind do |variable_path| set_factory_variables( schema_variables: factory_template.schema['properties']['variables']['properties'], From 1bef67f88e3fb3146e99ed9a373edfda78da887e Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Mon, 31 Jul 2023 10:09:39 -0400 Subject: [PATCH 196/665] Capture current behavior of policy load to create/update annot (cherry picked from commit c50cdeac804a8b7ee9ab6c8df55059363f46ff70) --- .../features/policy_load_annotations.feature | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 cucumber/api/features/policy_load_annotations.feature diff --git a/cucumber/api/features/policy_load_annotations.feature b/cucumber/api/features/policy_load_annotations.feature new file mode 100644 index 0000000000..2020ee2977 --- /dev/null +++ b/cucumber/api/features/policy_load_annotations.feature @@ -0,0 +1,427 @@ +@api +Feature: Updating Policies with Annotations + + The following describes the use-case for the different policy load types: + - PUT requests replace an existing policy, or loads a nonexistent one. + Requires update privilege on the target policy. + - POST requests add data to an existing policy. + Requires create privilege on the target policy. + - PATCH requests modify an existing policy. + Requires update privilege on the target policy. + + Here is a summary of the current behavior of Conjur's policy API, recording + the result of a host with [create|update] privilege on a policy branch + attempting to [add new|update existing] annotations to a resource in that + policy branch via a [PUT|POST|PATCH]-based policy load attempt: + - create / add new / PUT : EXPECTED FAIL - 403 on policy load + - create / add new / POST : EXPECTED SUCCESS + - create / add new / PATCH : EXPECTED FAIL - 403 on policy load + - create / update existing / PUT : EXPECTED FAIL - 403 on policy load + - create / update existing / POST : EXPECTED FAIL - 20x on policy load, annot not updated + - create / update existing / PATCH : EXPECTED FAIL - 403 on policy load + - update / add new / PUT : EXPECTED SUCCESS + - update / add new / POST : EXPECTED FAIL - 403 on policy load + - update / add new / PATCH : EXPECTED SUCCESS + - update / update existing / PUT : EXPECTED SUCCESS + - update / update existing / POST : EXPECTED FAIL - 403 on policy load + - update / update existing / PATCH : EXPECTED SUCCESS + + All these outcomes align with our expectations, but one may not align with + user expectations: ( create / update existing / POST ). A user may expect that + a policy load that tries and fails to update the content of a given annotation + should either provide a warning or fail outright. + + How can we update how we handle policy to fail in this case? + + Background: + Given I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy hosts + """ + And I successfully PUT "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Already annotated + + - !host to-annotate + """ + And I successfully POST "/policies/cucumber/policy/root" with body: + """ + - !user alice + - !user bob + + - !permit + resource: !policy hosts + privilege: [ read, update ] + role: !user bob + + - !permit + resource: !host hosts/annotated + privilege: [ read ] + role: !user bob + + - !permit + resource: !host hosts/to-annotate + privilege: [ read ] + role: !user bob + + - !permit + resource: !policy hosts + privilege: [ read, create ] + role: !user alice + + - !permit + resource: !host hosts/annotated + privilege: [ read ] + role: !user alice + + - !permit + resource: !host hosts/to-annotate + privilege: [ read ] + role: !user alice + """ + + Scenario: User with create privilege can NOT add new annotations with PUT + When I login as "alice" + And I save my place in the log file + Then I PUT "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: to-annotate + annotations: + description: Success + """ + Then the HTTP response status code is 403 + And The following appears in the log after my savepoint: + """ + CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts + """ + + Scenario: User with create privilege can add new annotations with POST + When I login as "alice" + Then I successfully POST "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: to-annotate + annotations: + description: Success + """ + And I successfully GET "/resources/cucumber/host/hosts%2Fto-annotate" + Then the JSON should be: + """ + { + "annotations": [ + { + "name": "description", + "policy": "cucumber:policy:hosts", + "value": "Success" + } + ], + "id": "cucumber:host:hosts/to-annotate", + "owner": "cucumber:policy:hosts", + "permissions": [ + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:bob" + }, + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:alice" + } + ], + "policy": "cucumber:policy:hosts", + "restricted_to": [ + + ] + } + """ + + Scenario: User with create privilege can NOT add new annotations with PATCH + When I login as "alice" + And I save my place in the log file + Then I PATCH "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: to-annotate + annotations: + description: Success + """ + Then the HTTP response status code is 403 + And The following appears in the log after my savepoint: + """ + CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts + """ + + Scenario: User with create privilege can NOT update existing annotations with PUT + When I login as "alice" + And I save my place in the log file + Then I PUT "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Success + """ + Then the HTTP response status code is 403 + And The following appears in the log after my savepoint: + """ + CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts + """ + + Scenario: User with create privilege CAN NOT update existing annotations with POST, but policy loads successfully + When I login as "alice" + Then I successfully POST "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Success + """ + And I successfully GET "/resources/cucumber/host/hosts%2Fannotated" + Then the JSON should be: + """ + { + "annotations": [ + { + "name": "description", + "policy": "cucumber:policy:hosts", + "value": "Already annotated" + } + ], + "id": "cucumber:host:hosts/annotated", + "owner": "cucumber:policy:hosts", + "permissions": [ + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:bob" + }, + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:alice" + } + ], + "policy": "cucumber:policy:hosts", + "restricted_to": [ + + ] + } + """ + + Scenario: User with create privilege can NOT update existing annotations with PATCH + When I login as "alice" + And I save my place in the log file + Then I PATCH "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Success + """ + Then the HTTP response status code is 403 + And The following appears in the log after my savepoint: + """ + CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts + """ + + Scenario: User with update privilege can add new annotations with PUT + When I login as "bob" + Then I successfully PUT "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: to-annotate + annotations: + description: Success + """ + And I successfully GET "/resources/cucumber/host/hosts%2Fto-annotate" + Then the JSON should be: + """ + { + "annotations": [ + { + "name": "description", + "policy": "cucumber:policy:hosts", + "value": "Success" + } + ], + "id": "cucumber:host:hosts/to-annotate", + "owner": "cucumber:policy:hosts", + "permissions": [ + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:bob" + }, + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:alice" + } + ], + "policy": "cucumber:policy:hosts", + "restricted_to": [ + + ] + } + """ + + Scenario: User with update privilege can NOT add new annotations with POST + When I login as "bob" + And I save my place in the log file + Then I POST "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: to-annotate + annotations: + description: Success + """ + Then the HTTP response status code is 403 + And The following appears in the log after my savepoint: + """ + CONJ00006E 'cucumber:user:bob' does not have 'create' privilege on cucumber:policy:hosts + """ + + Scenario: User with update privilege can add new annotations with PATCH + When I login as "bob" + Then I successfully PATCH "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: to-annotate + annotations: + description: Success + """ + And I successfully GET "/resources/cucumber/host/hosts%2Fto-annotate" + Then the JSON should be: + """ + { + "annotations": [ + { + "name": "description", + "policy": "cucumber:policy:hosts", + "value": "Success" + } + ], + "id": "cucumber:host:hosts/to-annotate", + "owner": "cucumber:policy:hosts", + "permissions": [ + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:bob" + }, + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:alice" + } + ], + "policy": "cucumber:policy:hosts", + "restricted_to": [ + + ] + } + """ + + Scenario: User with update privilege can update existing annotations with PUT + When I login as "bob" + Then I successfully PUT "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Success + """ + And I successfully GET "/resources/cucumber/host/hosts%2Fannotated" + Then the JSON should be: + """ + { + "annotations": [ + { + "name": "description", + "policy": "cucumber:policy:hosts", + "value": "Success" + } + ], + "id": "cucumber:host:hosts/annotated", + "owner": "cucumber:policy:hosts", + "permissions": [ + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:bob" + }, + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:alice" + } + ], + "policy": "cucumber:policy:hosts", + "restricted_to": [ + + ] + } + """ + + Scenario: User with update privilege can NOT update existing annotations with POST + When I login as "bob" + And I save my place in the log file + Then I POST "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Success + """ + Then the HTTP response status code is 403 + And The following appears in the log after my savepoint: + """ + CONJ00006E 'cucumber:user:bob' does not have 'create' privilege on cucumber:policy:hosts + """ + + Scenario: User with update privilege can update existing annotations with PATCH + When I login as "bob" + Then I successfully PATCH "/policies/cucumber/policy/hosts" with body: + """ + - !host + id: annotated + annotations: + description: Success + """ + And I successfully GET "/resources/cucumber/host/hosts%2Fannotated" + Then the JSON should be: + """ + { + "annotations": [ + { + "name": "description", + "policy": "cucumber:policy:hosts", + "value": "Success" + } + ], + "id": "cucumber:host:hosts/annotated", + "owner": "cucumber:policy:hosts", + "permissions": [ + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:bob" + }, + { + "policy": "cucumber:policy:root", + "privilege": "read", + "role": "cucumber:user:alice" + } + ], + "policy": "cucumber:policy:hosts", + "restricted_to": [ + + ] + } + """ From 266835f323f70885d4f52c73e46b22d5ac698f77 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Mon, 14 Aug 2023 16:34:31 -0400 Subject: [PATCH 197/665] Tag new Cucumber tests (cherry picked from commit 36c8dbe819c86dea54d6f9024f8696cdee5d6760) --- .../features/policy_load_annotations.feature | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cucumber/api/features/policy_load_annotations.feature b/cucumber/api/features/policy_load_annotations.feature index 2020ee2977..cc2e52c8b5 100644 --- a/cucumber/api/features/policy_load_annotations.feature +++ b/cucumber/api/features/policy_load_annotations.feature @@ -84,6 +84,8 @@ Feature: Updating Policies with Annotations role: !user alice """ + @negative + @acceptance Scenario: User with create privilege can NOT add new annotations with PUT When I login as "alice" And I save my place in the log file @@ -100,6 +102,8 @@ Feature: Updating Policies with Annotations CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts """ + @smoke + @acceptance Scenario: User with create privilege can add new annotations with POST When I login as "alice" Then I successfully POST "/policies/cucumber/policy/hosts" with body: @@ -141,6 +145,8 @@ Feature: Updating Policies with Annotations } """ + @negative + @acceptance Scenario: User with create privilege can NOT add new annotations with PATCH When I login as "alice" And I save my place in the log file @@ -157,6 +163,8 @@ Feature: Updating Policies with Annotations CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts """ + @negative + @acceptance Scenario: User with create privilege can NOT update existing annotations with PUT When I login as "alice" And I save my place in the log file @@ -173,6 +181,8 @@ Feature: Updating Policies with Annotations CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts """ + @negative + @acceptance Scenario: User with create privilege CAN NOT update existing annotations with POST, but policy loads successfully When I login as "alice" Then I successfully POST "/policies/cucumber/policy/hosts" with body: @@ -214,6 +224,8 @@ Feature: Updating Policies with Annotations } """ + @negative + @acceptance Scenario: User with create privilege can NOT update existing annotations with PATCH When I login as "alice" And I save my place in the log file @@ -230,6 +242,8 @@ Feature: Updating Policies with Annotations CONJ00006E 'cucumber:user:alice' does not have 'update' privilege on cucumber:policy:hosts """ + @smoke + @acceptance Scenario: User with update privilege can add new annotations with PUT When I login as "bob" Then I successfully PUT "/policies/cucumber/policy/hosts" with body: @@ -271,6 +285,8 @@ Feature: Updating Policies with Annotations } """ + @negative + @acceptance Scenario: User with update privilege can NOT add new annotations with POST When I login as "bob" And I save my place in the log file @@ -287,6 +303,8 @@ Feature: Updating Policies with Annotations CONJ00006E 'cucumber:user:bob' does not have 'create' privilege on cucumber:policy:hosts """ + @smoke + @acceptance Scenario: User with update privilege can add new annotations with PATCH When I login as "bob" Then I successfully PATCH "/policies/cucumber/policy/hosts" with body: @@ -328,6 +346,8 @@ Feature: Updating Policies with Annotations } """ + @smoke + @acceptance Scenario: User with update privilege can update existing annotations with PUT When I login as "bob" Then I successfully PUT "/policies/cucumber/policy/hosts" with body: @@ -369,6 +389,8 @@ Feature: Updating Policies with Annotations } """ + @negative + @acceptance Scenario: User with update privilege can NOT update existing annotations with POST When I login as "bob" And I save my place in the log file @@ -385,6 +407,8 @@ Feature: Updating Policies with Annotations CONJ00006E 'cucumber:user:bob' does not have 'create' privilege on cucumber:policy:hosts """ + @smoke + @acceptance Scenario: User with update privilege can update existing annotations with PATCH When I login as "bob" Then I successfully PATCH "/policies/cucumber/policy/hosts" with body: From e07bcd2c3a33bd3f0ce87a3f23c974e718df26c0 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Thu, 17 Aug 2023 15:15:57 -0400 Subject: [PATCH 198/665] Fail additive policy load requests that update existing resources (cherry picked from commit 9141a2dc60c9097eafcbc0db5e0d317f7a4d7a8f) --- CHANGELOG.md | 3 ++ app/models/loader/create_policy.rb | 2 +- app/models/loader/orchestrate.rb | 8 ++-- .../features/policy_load_annotations.feature | 46 +++---------------- .../api/features/policy_load_modes.feature | 15 ++---- 5 files changed, 21 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 008ccbe908..79fad05347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. mitigates the possibility of a web worker becoming starved while waiting for a connection to become available. [cyberark/conjur#2875](https://github.com/cyberark/conjur/pull/2875) +- Additive policy requests submitted via POST are rejected with a 400 status if + they attempt to update an existing resource. + [cyberark/conjur#2888](https://github.com/cyberark/conjur/pull/2888) ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. diff --git a/app/models/loader/create_policy.rb b/app/models/loader/create_policy.rb index f3aac3dc67..128a179edc 100644 --- a/app/models/loader/create_policy.rb +++ b/app/models/loader/create_policy.rb @@ -16,7 +16,7 @@ def call @loader.delete_shadowed_and_duplicate_rows - @loader.store_policy_in_db + @loader.store_policy_in_db(reject_duplicates: true) @loader.release_db_connection end diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index fce6f491e5..2290e2ae34 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -120,8 +120,9 @@ def delete_shadowed_and_duplicate_rows end # TODO: consider renaming this method - def store_policy_in_db - eliminate_duplicates_pk + def store_policy_in_db(reject_duplicates: false) + removed_duplicates_count = eliminate_duplicates_pk + raise ApplicationController::BadRequest, "Updating existing resource disallowed in additive policy operation" if removed_duplicates_count.positive? && reject_duplicates insert_new @@ -243,8 +244,9 @@ def eliminate_duplicates_exact end # Delete rows from the new policy which have the same primary keys as existing rows. + # Returns the total number of deleted rows. def eliminate_duplicates_pk - TABLES.each do |table| + TABLES.sum do |table| eliminate_duplicates(table, Array(model_for_table(table).primary_key) + [ :policy_id ]) end end diff --git a/cucumber/api/features/policy_load_annotations.feature b/cucumber/api/features/policy_load_annotations.feature index cc2e52c8b5..c21c547409 100644 --- a/cucumber/api/features/policy_load_annotations.feature +++ b/cucumber/api/features/policy_load_annotations.feature @@ -17,7 +17,7 @@ Feature: Updating Policies with Annotations - create / add new / POST : EXPECTED SUCCESS - create / add new / PATCH : EXPECTED FAIL - 403 on policy load - create / update existing / PUT : EXPECTED FAIL - 403 on policy load - - create / update existing / POST : EXPECTED FAIL - 20x on policy load, annot not updated + - create / update existing / POST : EXPECTED FAIL - 400 on policy load - create / update existing / PATCH : EXPECTED FAIL - 403 on policy load - update / add new / PUT : EXPECTED SUCCESS - update / add new / POST : EXPECTED FAIL - 403 on policy load @@ -26,13 +26,6 @@ Feature: Updating Policies with Annotations - update / update existing / POST : EXPECTED FAIL - 403 on policy load - update / update existing / PATCH : EXPECTED SUCCESS - All these outcomes align with our expectations, but one may not align with - user expectations: ( create / update existing / POST ). A user may expect that - a policy load that tries and fails to update the content of a given annotation - should either provide a warning or fail outright. - - How can we update how we handle policy to fail in this case? - Background: Given I am the super-user And I successfully PUT "/policies/cucumber/policy/root" with body: @@ -183,45 +176,20 @@ Feature: Updating Policies with Annotations @negative @acceptance - Scenario: User with create privilege CAN NOT update existing annotations with POST, but policy loads successfully + Scenario: User with create privilege CAN NOT update existing annotations with POST When I login as "alice" - Then I successfully POST "/policies/cucumber/policy/hosts" with body: + And I save my place in the log file + Then I POST "/policies/cucumber/policy/hosts" with body: """ - !host id: annotated annotations: description: Success """ - And I successfully GET "/resources/cucumber/host/hosts%2Fannotated" - Then the JSON should be: + Then the HTTP response status code is 400 + And The following appears in the log after my savepoint: """ - { - "annotations": [ - { - "name": "description", - "policy": "cucumber:policy:hosts", - "value": "Already annotated" - } - ], - "id": "cucumber:host:hosts/annotated", - "owner": "cucumber:policy:hosts", - "permissions": [ - { - "policy": "cucumber:policy:root", - "privilege": "read", - "role": "cucumber:user:bob" - }, - { - "policy": "cucumber:policy:root", - "privilege": "read", - "role": "cucumber:user:alice" - } - ], - "policy": "cucumber:policy:hosts", - "restricted_to": [ - - ] - } + Updating existing resource disallowed in additive policy operation """ @negative diff --git a/cucumber/api/features/policy_load_modes.feature b/cucumber/api/features/policy_load_modes.feature index 3f0a66ac20..eed60df3ef 100644 --- a/cucumber/api/features/policy_load_modes.feature +++ b/cucumber/api/features/policy_load_modes.feature @@ -172,22 +172,17 @@ Feature: Updating policies @acceptance Scenario: POST cannot update existing policy records - When I successfully POST "/policies/cucumber/policy/dev/db" with body: + When I save my place in the log file + And I POST "/policies/cucumber/policy/dev/db" with body: """ - !variable id: b kind: private key """ - When I successfully GET "/resources/cucumber/variable/dev/db/b" - Then the JSON at "annotations" should be: + Then the HTTP response status code is 400 + And The following appears in the log after my savepoint: """ - [ - { - "name": "conjur/kind", - "policy": "cucumber:policy:dev/db", - "value": "password" - } - ] + Updating existing resource disallowed in additive policy operation """ @negative @acceptance From 36a6a1eb37d8aa55bb00bab370cd494ea9e08d8a Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Fri, 18 Aug 2023 15:42:39 -0400 Subject: [PATCH 199/665] Create and use unique DisallowedPolicyOperation exception (cherry picked from commit b1028a102c5c604b460d941acb60976729f4a4b3) --- app/controllers/application_controller.rb | 12 ++++++++++++ app/models/exceptions/disallowed_policy_operation.rb | 11 +++++++++++ app/models/loader/orchestrate.rb | 2 +- .../api/features/policy_load_annotations.feature | 4 ++-- cucumber/api/features/policy_load_modes.feature | 2 +- 5 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 app/models/exceptions/disallowed_policy_operation.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fababebd8a..67ab27d519 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -60,6 +60,7 @@ class UnprocessableEntity < RuntimeError rescue_from Sequel::ForeignKeyConstraintViolation, with: :foreign_key_constraint_violation rescue_from Conjur::PolicyParser::Invalid, with: :policy_invalid rescue_from Exceptions::InvalidPolicyObject, with: :policy_invalid + rescue_from Exceptions::DisallowedPolicyOperation, with: :disallowed_policy_operation rescue_from ArgumentError, with: :argument_error rescue_from ActionController::ParameterMissing, with: :argument_error rescue_from UnprocessableEntity, with: :unprocessable_entity @@ -194,6 +195,17 @@ def policy_invalid e render(json: { error: error }, status: :unprocessable_entity) end + def disallowed_policy_operation e + logger.debug("#{e}\n#{e.backtrace.join("\n")}") + + render(json: { + error: { + code: "disallowed_policy_operation", + message: e.message + } + }, status: :unprocessable_entity) + end + def argument_error e logger.debug("#{e}\n#{e.backtrace.join("\n")}") diff --git a/app/models/exceptions/disallowed_policy_operation.rb b/app/models/exceptions/disallowed_policy_operation.rb new file mode 100644 index 0000000000..84d34f0bbf --- /dev/null +++ b/app/models/exceptions/disallowed_policy_operation.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Exceptions + class DisallowedPolicyOperation < RuntimeError + + def initialize + super("Updating existing resource disallowed in additive policy operation") + end + + end +end diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index 2290e2ae34..c5e408a31d 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -122,7 +122,7 @@ def delete_shadowed_and_duplicate_rows # TODO: consider renaming this method def store_policy_in_db(reject_duplicates: false) removed_duplicates_count = eliminate_duplicates_pk - raise ApplicationController::BadRequest, "Updating existing resource disallowed in additive policy operation" if removed_duplicates_count.positive? && reject_duplicates + raise Exceptions::DisallowedPolicyOperation if removed_duplicates_count.positive? && reject_duplicates insert_new diff --git a/cucumber/api/features/policy_load_annotations.feature b/cucumber/api/features/policy_load_annotations.feature index c21c547409..4ecad06154 100644 --- a/cucumber/api/features/policy_load_annotations.feature +++ b/cucumber/api/features/policy_load_annotations.feature @@ -17,7 +17,7 @@ Feature: Updating Policies with Annotations - create / add new / POST : EXPECTED SUCCESS - create / add new / PATCH : EXPECTED FAIL - 403 on policy load - create / update existing / PUT : EXPECTED FAIL - 403 on policy load - - create / update existing / POST : EXPECTED FAIL - 400 on policy load + - create / update existing / POST : EXPECTED FAIL - 422 on policy load - create / update existing / PATCH : EXPECTED FAIL - 403 on policy load - update / add new / PUT : EXPECTED SUCCESS - update / add new / POST : EXPECTED FAIL - 403 on policy load @@ -186,7 +186,7 @@ Feature: Updating Policies with Annotations annotations: description: Success """ - Then the HTTP response status code is 400 + Then the HTTP response status code is 422 And The following appears in the log after my savepoint: """ Updating existing resource disallowed in additive policy operation diff --git a/cucumber/api/features/policy_load_modes.feature b/cucumber/api/features/policy_load_modes.feature index eed60df3ef..ec7a031414 100644 --- a/cucumber/api/features/policy_load_modes.feature +++ b/cucumber/api/features/policy_load_modes.feature @@ -179,7 +179,7 @@ Feature: Updating policies id: b kind: private key """ - Then the HTTP response status code is 400 + Then the HTTP response status code is 422 And The following appears in the log after my savepoint: """ Updating existing resource disallowed in additive policy operation From 93f0aca797e6c7669584c08ed13abcf3ef6545a9 Mon Sep 17 00:00:00 2001 From: Andy Tinkham Date: Thu, 24 Aug 2023 16:04:30 -0500 Subject: [PATCH 200/665] Remove oidc_connect gem test private key Signed-off-by: Andy Tinkham (cherry picked from commit 7d6083d7109d8238a8dfcf3e12cddf1c46a245c4) --- Dockerfile | 9 +++++---- Dockerfile.ubi | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7eb34d0c5e..8bad96e8f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,13 +33,14 @@ COPY Gemfile \ COPY gems/ gems/ -RUN bundle --without test development +RUN bundle --without test development && \ + # Remove private keys brought in by gems in their test data + find / -name openid_connect -type d -exec find {} -name '*.pem' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + find / -name httpclient -type d -exec find {} -name '*.pem' -type f -delete \; COPY . . -# removing CA bundle of httpclient gem -RUN find / -name httpclient -type d -exec find {} -name *.pem -type f -delete \; - RUN ln -sf /opt/conjur-server/bin/conjurctl /usr/local/bin/ ENV RAILS_ENV production diff --git a/Dockerfile.ubi b/Dockerfile.ubi index 53a90115af..ac539680d3 100644 --- a/Dockerfile.ubi +++ b/Dockerfile.ubi @@ -76,7 +76,9 @@ RUN INSTALL_PKGS="gcc \ yum -y clean all --enablerepo='*' && \ # removing CA bundle of httpclient gem find / -name 'httpclient-*' -type d -exec find {} -name '*.pem' -type f -delete \; && \ - find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + # remove the private key in the oidc_connect gem spec directory + find / -name openid_connect -type d -exec find {} -name '*.pem' -type f -delete \; COPY . . From 0ef295cff75a2c98651ce3874f1c1d5b0fec7a85 Mon Sep 17 00:00:00 2001 From: Matthew Felgate Date: Mon, 28 Aug 2023 11:54:31 -0400 Subject: [PATCH 201/665] Update puma to version 6 (cherry picked from commit bd84554c0776918c9b4801357e2b681faa84e499) --- CHANGELOG.md | 2 ++ Gemfile | 2 +- Gemfile.lock | 6 +++--- NOTICES.txt | 4 ++-- ci/shared.sh | 2 +- ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh | 2 +- config/puma.rb | 1 - 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fad05347..385f2f462f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. fail safe and the negative privilege statement (revoke, deny) is the final outcome [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) +- Update puma to 6.3.1 to address CVE-2023-40175. + [CNJR-2564](https://ca-il-jira.il.cyber-ark.com:8443/browse/CNJR-2564) ## [1.19.5] - 2023-06-29 diff --git a/Gemfile b/Gemfile index a531576253..a471255703 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem 'http', '~> 4.2.0' gem 'iso8601' gem 'jbuilder', '~> 2.7.0' gem 'nokogiri', '>= 1.8.2' -gem 'puma', '~> 5.6' +gem 'puma', '~> 6' gem 'rack', '~> 2.2' gem 'rails', '~> 6.1', '>= 6.1.4.6' gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock index c42420713b..bec5b8fd2a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -356,7 +356,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (5.0.1) - puma (5.6.4) + puma (6.3.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.7.1) @@ -580,14 +580,14 @@ DEPENDENCIES net-ldap net-ssh nokogiri (>= 1.8.2) - openid_connect (= 2.2.0) + openid_connect (~> 2.0) parallel parallel_tests pg prometheus-client pry-byebug pry-rails - puma (~> 5.6) + puma (~> 6) rack (~> 2.2) rack-rewrite rails (~> 6.1, >= 6.1.4.6) diff --git a/NOTICES.txt b/NOTICES.txt index 9f776f0a92..d648c1f961 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -20,7 +20,7 @@ Section 3: BSD-3-Clause >>> https://rubygems.org/gems/base32-crockford/versions/0.1.0 >>> https://rubygems.org/gems/ffi/versions/1.15.4 ->>> https://rubygems.org/gems/puma/versions/5.6.4 +>>> https://rubygems.org/gems/puma/versions/6.3.1 Section 4: MIT @@ -214,7 +214,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ->>> https://rubygems.org/gems/puma/versions/5.6.4 +>>> https://rubygems.org/gems/puma/versions/6.3.1 Some code copyright (c) 2005, Zed Shaw Copyright (c) 2011, Evan Phoenix diff --git a/ci/shared.sh b/ci/shared.sh index 74aaf08f10..081fb0b4f6 100644 --- a/ci/shared.sh +++ b/ci/shared.sh @@ -185,7 +185,7 @@ _run_cucumber_tests() { # process to write the report. The container is kept alive using an infinite # sleep in the at_exit hook (see .simplecov). for parallel_service in "${parallel_services[@]}"; do - $COMPOSE exec -T "$parallel_service" bash -c "pkill -f 'puma 5'" + $COMPOSE exec -T "$parallel_service" bash -c "pkill -f 'puma 6'" done } diff --git a/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh b/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh index d01e8f1d52..207fad3997 100755 --- a/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh +++ b/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh @@ -57,7 +57,7 @@ function finish { echo "Killing conjur so that coverage report is written" # The container is kept alive using an infinite sleep in the at_exit hook # (see .simplecov) so that the kubectl cp below works. - kubectl exec "${conjur_pod_name}" -- bash -c "pkill -f 'puma 5'" + kubectl exec "${conjur_pod_name}" -- bash -c "pkill -f 'puma 6'" echo "Retrieving coverage report" kubectl cp \ diff --git a/config/puma.rb b/config/puma.rb index f8b8c5579e..d996f7f5b2 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -65,7 +65,6 @@ # available in this config file. preload_app! -rackup DefaultRackup port ENV['PORT'] || 3000 environment ENV['RACK_ENV'] || 'development' From 70b73b59276581ac312c1d6dcf48631d0f3e3b48 Mon Sep 17 00:00:00 2001 From: Matthew Felgate Date: Mon, 28 Aug 2023 23:44:07 +0300 Subject: [PATCH 202/665] Update smaller gem files (cherry picked from commit f8e52b70ef7ed366313635ff61e00a817523fe42) --- Gemfile | 6 +++++- Gemfile.lock | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index a471255703..d05e7f6765 100644 --- a/Gemfile +++ b/Gemfile @@ -62,6 +62,9 @@ gem 'net-ldap' # for AWS rotator gem 'aws-sdk-iam', require: false +# we need this version since any newer introduces braking change that causes issues with safe_yaml: https://github.com/ruby/psych/discussions/571 +gem 'psych', '=3.3.2' + group :production do gem 'rails_12factor' end @@ -90,6 +93,7 @@ group :development, :test do gem 'cucumber', '~> 7.1' gem 'database_cleaner', '~> 1.8' gem 'debase', '~> 0.2.5.beta2' + gem 'debase-ruby_core_source', '~> 3.2.1' gem 'json_spec', '~> 1.1' gem 'faye-websocket' gem 'net-ssh' @@ -103,7 +107,7 @@ group :development, :test do gem 'rspec' gem 'rspec-core' gem 'rspec-rails' - gem 'ruby-debug-ide' + # gem 'ruby-debug-ide' # We use a post-coverage hook to sleep covered processes until we're ready to # collect the coverage reports in CI. Because of this, we don't want bundler diff --git a/Gemfile.lock b/Gemfile.lock index bec5b8fd2a..872293af57 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -184,7 +184,7 @@ GEM date (3.3.3) debase (0.2.5.beta2) debase-ruby_core_source (>= 0.10.12) - debase-ruby_core_source (0.10.13) + debase-ruby_core_source (3.2.1) deep_merge (1.2.2) diff-lcs (1.4.4) docile (1.4.0) @@ -355,6 +355,7 @@ GEM pry (~> 0.13.0) pry-rails (0.3.9) pry (>= 0.10.4) + psych (3.3.2) public_suffix (5.0.1) puma (6.3.1) nio4r (~> 2.0) @@ -458,8 +459,6 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) rubocop-checkstyle_formatter (0.4.0) rubocop (>= 0.35.1) - ruby-debug-ide (0.7.3) - rake (>= 0.8.1) ruby-next-core (0.14.0) ruby-progressbar (1.11.0) ruby2_keywords (0.0.5) @@ -560,6 +559,7 @@ DEPENDENCIES cucumber (~> 7.1) database_cleaner (~> 1.8) debase (~> 0.2.5.beta2) + debase-ruby_core_source (~> 3.2.1) dry-struct dry-types event_emitter @@ -587,6 +587,7 @@ DEPENDENCIES prometheus-client pry-byebug pry-rails + psych (= 3.3.2) puma (~> 6) rack (~> 2.2) rack-rewrite From 67a0ddd2f69ee0966540a5256fd9c35edd112eea Mon Sep 17 00:00:00 2001 From: Matthew Felgate Date: Tue, 29 Aug 2023 11:48:10 -0400 Subject: [PATCH 203/665] Update jwt gem (cherry picked from commit cab6fffb4977c5314262fb8e0be33e539e6657cb) --- Gemfile | 3 ++- Gemfile.lock | 5 ++--- NOTICES.txt | 8 ++++---- .../features/authn_jwt_check_standard_claims.feature | 4 ++-- .../features/authn_jwt_fetch_signing_key.feature | 4 ++-- .../features/authn_jwt_validate_and_decode.feature | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index d05e7f6765..d113bdd311 100644 --- a/Gemfile +++ b/Gemfile @@ -75,7 +75,8 @@ gem 'kubeclient' gem 'websocket' # authn-oidc, gcp, azure, jwt -gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements +# gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements +gem 'jwt', '2.7.1' # authn-oidc gem 'openid_connect', '~> 2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 872293af57..8eb2dca0ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -282,7 +282,7 @@ GEM rspec (>= 2.0, < 4.0) jsonpath (1.1.0) multi_json - jwt (2.2.2) + jwt (2.7.1) kubeclient (4.9.3) http (>= 3.0, < 5.0) jsonpath (~> 1.0) @@ -573,7 +573,7 @@ DEPENDENCIES jbuilder (~> 2.7.0) json_schemer json_spec (~> 1.1) - jwt (= 2.2.2) + jwt (= 2.7.1) kubeclient listen loofah (>= 2.2.3) @@ -604,7 +604,6 @@ DEPENDENCIES rspec-rails rubocop (~> 0.58.0) rubocop-checkstyle_formatter - ruby-debug-ide rufus-scheduler sequel sequel-pg_advisory_locking diff --git a/NOTICES.txt b/NOTICES.txt index d648c1f961..e30c658d96 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -37,13 +37,13 @@ Section 4: MIT >>> https://rubygems.org/gems/http/versions/4.2.0 >>> https://rubygems.org/gems/iso8601/versions/0.13.0 >>> https://rubygems.org/gems/jbuilder/versions/2.7.0 ->>> https://rubygems.org/gems/jwt/versions/2.2.2 +>>> https://rubygems.org/gems/jwt/versions/2.7.1 >>> https://rubygems.org/gems/kubeclient/versions/4.9.3 >>> https://rubygems.org/gems/listen/versions/3.7.0 >>> https://rubygems.org/gems/loofah/versions/2.20.0 >>> https://rubygems.org/gems/net-ldap/versions/0.17.0 >>> https://rubygems.org/gems/nokogiri/versions/1.14.3 ->>> https://rubygems.org/gems/openid_connect/versions/1.3.0 +>>> https://rubygems.org/gems/openid_connect/versions/2.2.0 >>> https://rubygems.org/gems/rack-rewrite/versions/1.5.1 >>> https://rubygems.org/gems/rails/versions/6.1.7.3 >>> https://rubygems.org/gems/rake/versions/13.0.6 @@ -546,7 +546,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/jwt/versions/2.2.2 +>>> https://rubygems.org/gems/jwt/versions/2.7.1 Copyright (c) 2011 Jeff Lindsay @@ -680,7 +680,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/openid_connect/versions/1.3.0 +>>> https://rubygems.org/gems/openid_connect/versions/2.2.0 Copyright (c) 2011 nov matake diff --git a/cucumber/authenticators_jwt/features/authn_jwt_check_standard_claims.feature b/cucumber/authenticators_jwt/features/authn_jwt_check_standard_claims.feature index 28fb3af4dc..4d002926ee 100644 --- a/cucumber/authenticators_jwt/features/authn_jwt_check_standard_claims.feature +++ b/cucumber/authenticators_jwt/features/authn_jwt_check_standard_claims.feature @@ -365,7 +365,7 @@ Feature: JWT Authenticator - Check registered claim Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00035E Failed to decode token (3rdPartyError ='#')> + CONJ00035E Failed to decode token (3rdPartyError ='#')> """ @negative @acceptance @@ -454,7 +454,7 @@ Feature: JWT Authenticator - Check registered claim Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00035E Failed to decode token (3rdPartyError ='#')> + CONJ00035E Failed to decode token (3rdPartyError ='#')> """ @sanity diff --git a/cucumber/authenticators_jwt/features/authn_jwt_fetch_signing_key.feature b/cucumber/authenticators_jwt/features/authn_jwt_fetch_signing_key.feature index b4b3e864ae..6bfcc7aead 100644 --- a/cucumber/authenticators_jwt/features/authn_jwt_fetch_signing_key.feature +++ b/cucumber/authenticators_jwt/features/authn_jwt_fetch_signing_key.feature @@ -550,7 +550,7 @@ Feature: JWT Authenticator - Fetch signing key Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00035E Failed to decode token (3rdPartyError ='#') + CONJ00035E Failed to decode token (3rdPartyError ='#') """ @negative @acceptance @@ -605,7 +605,7 @@ Feature: JWT Authenticator - Fetch signing key Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00035E Failed to decode token (3rdPartyError ='#') + CONJ00035E Failed to decode token (3rdPartyError ='#') """ @negative @acceptance diff --git a/cucumber/authenticators_jwt/features/authn_jwt_validate_and_decode.feature b/cucumber/authenticators_jwt/features/authn_jwt_validate_and_decode.feature index 6cd17e769c..9e90b39147 100644 --- a/cucumber/authenticators_jwt/features/authn_jwt_validate_and_decode.feature +++ b/cucumber/authenticators_jwt/features/authn_jwt_validate_and_decode.feature @@ -77,7 +77,7 @@ Feature: JWT Authenticator - Validate And Decode Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00035E Failed to decode token (3rdPartyError ='#')> + CONJ00035E Failed to decode token (3rdPartyError ='#')> """ @negative @acceptance @@ -102,5 +102,5 @@ Feature: JWT Authenticator - Validate And Decode Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00035E Failed to decode token (3rdPartyError ='#')> + CONJ00035E Failed to decode token (3rdPartyError ='#')> """ From 3041098506415186bad98d869967e4025317f419 Mon Sep 17 00:00:00 2001 From: Shlomo Heigh Date: Wed, 30 Aug 2023 12:12:26 -0400 Subject: [PATCH 204/665] Fix changelog links (cherry picked from commit 31e750076890e4f37a82346f4d0f9359dc7b8ef5) --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 385f2f462f..90a498c1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,14 +171,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Security - Support plural syntax for revoke and deny - [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) + [cyberark/conjur#2901](https://github.com/cyberark/conjur/pull/2901) - Previously, attempting to add and remove a privilege in the same policy load resulted in only the positive privilege (grant, permit) taking effect. Now we fail safe and the negative privilege statement (revoke, deny) is the final outcome - [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) + [cyberark/conjur#2907](https://github.com/cyberark/conjur/pull/2907) - Update puma to 6.3.1 to address CVE-2023-40175. - [CNJR-2564](https://ca-il-jira.il.cyber-ark.com:8443/browse/CNJR-2564) + [cyberark/conjur#2925](https://github.com/cyberark/conjur/pull/2925) ## [1.19.5] - 2023-06-29 From 7c550ca33a0cdf40e1068f2bdacb90afe29e02f2 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Wed, 23 Aug 2023 21:53:41 -0400 Subject: [PATCH 205/665] DevEnv: Set COMPOSE envvar before sourcing keycloak_functions.sh (cherry picked from commit be2616a09777f33d06547e393ef59d7140ea4d12) --- dev/start | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/start b/dev/start index ec91de8df9..0a5f8431be 100755 --- a/dev/start +++ b/dev/start @@ -238,6 +238,7 @@ setup_keycloak() { pushd "../ci" # CC servers can't find it for some reason. Local shellcheck is fine. # shellcheck disable=SC1091 + export COMPOSE="docker compose" source "oauth/keycloak/keycloak_functions.sh" popd From 992f1f9261448af63f05c87511e0e3dd9f7b0a1b Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Wed, 23 Aug 2023 21:55:04 -0400 Subject: [PATCH 206/665] AuthnOIDC: optionally write temp certs before provider discovery (cherry picked from commit 278f4c511c095804a9ca7bb94b7d41e0634d30a5) --- .../authentication/authn_oidc/v2/client.rb | 36 ++++ .../authn-oidc/v2/client_spec.rb | 182 ++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb index 8f69559395..896dc2730d 100644 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ b/app/domain/authentication/authn_oidc/v2/client.rb @@ -110,6 +110,42 @@ def discovery_information(invalidate: false) raise Errors::Authentication::OAuth::ProviderDiscoveryFailed.new(@authenticator.provider_uri, e.message) end end + + # discover wraps ::OpenIDConnect::Discovery::Provider::Config.discover! + # with commands to write & clean up a certificate to & from the default + # Conjur container certificate store. + # + # The temporary certificate file name is "x.0", where x is the hash of + # the certificate subject name. If this file already exists in the + # default cert store, the original certificate is used. + # + # discover is a class method, because there are a few contexts outside + # this class where the underlying discover! method is used. Call it by + # running Authentication::AuthnOIDC::Client.discover(...). + def self.discover( + provider_uri:, + discovery_configuration: ::OpenIDConnect::Discovery::Provider::Config, + cert_dir: OpenSSL::X509::DEFAULT_CERT_DIR, + cert_string: nil + ) + d = -> { discovery_configuration.discover!(provider_uri) } + + return d.call if cert_string.blank? + + cert = OpenSSL::X509::Certificate.new(cert_string) + symlink = File.join(cert_dir, "#{cert.subject.hash.to_s(16)}.0") + return d.call if File.exist?(symlink) + + Dir.mktmpdir do |tmp_dir| + tmp_file = File.join(tmp_dir, 'ca.pem') + File.write(tmp_file, cert_string) + File.symlink(tmp_file, symlink) + + d.call + ensure + File.unlink(symlink) if symlink.present? && File.symlink?(symlink) + end + end end end end diff --git a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb index 9fdf0190d6..33ca3c6800 100644 --- a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb @@ -186,6 +186,188 @@ def client(config) end end end + + describe '.discover', type: 'unit' do + let(:target) { Authentication::AuthnOidc::V2::Client } + let(:provider_uri) { "https://oidcprovider.com" } + let(:mock_discovery) { double("Mock Discovery Config") } + let(:mock_response) { "Mock Discovery Response" } + + before(:each) do + @cert_dir = Dir.mktmpdir + end + + after(:each) do + FileUtils.remove_entry @cert_dir + end + + context 'when no cert is required' do + context 'when credentials are valid', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/discovery_endpoint-valid_oidc_credentials" do + it 'endpoint return valid data' do + resp = target.discover(provider_uri: config[:provider_uri]) + + expect(resp.authorization_endpoint).to eq("https://#{config[:host]}#{config[:expected_authz]}") + expect(resp.token_endpoint).to eq("https://#{config[:host]}#{config[:expected_token]}") + expect(resp.userinfo_endpoint).to eq("https://#{config[:host]}#{config[:expected_userinfo]}") + expect(resp.jwks_uri).to eq("https://#{config[:host]}#{config[:expected_keys]}") + end + end + + context 'when provider URI is invalid', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/discovery_endpoint-invalid_oidc_provider" do + it 'raises an error' do + expect do + target.discover(provider_uri: "https://foo.bar.com") + end.to raise_error( + OpenIDConnect::Discovery::DiscoveryFailed + ) + end + end + end + + context 'when cert is not provided' do + it 'does not write the certificate' do + allow(mock_discovery).to receive(:discover!).with(String) do + expect(Dir.entries(@cert_dir).select do |entry| + entry unless [".", ".."].include?(entry) + end).to be_empty + end + + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: "" + ) + end + + it 'returns the discovery response' do + allow(mock_discovery).to receive(:discover!).with(String).and_return( + mock_response + ) + + expect(target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: "" + )).to eq(mock_response) + end + end + + context 'when valid cert is provided' do + let(:cert) { <<~EOF + -----BEGIN CERTIFICATE----- + MIIDqzCCApOgAwIBAgIJAP9vSJDyPfQdMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNV + BAYTAlVTMRYwFAYDVQQIDA1NYXNzYWNodXNldHRzMQ8wDQYDVQQHDAZOZXd0b24x + ETAPBgNVBAoMCEN5YmVyQXJrMQ8wDQYDVQQLDAZDb25qdXIxEDAOBgNVBAMMB1Jv + b3QgQ0EwHhcNMjMwODIzMjIyMjU1WhcNMzExMTA5MjIyMjU1WjBsMQswCQYDVQQG + EwJVUzEWMBQGA1UECAwNTWFzc2FjaHVzZXR0czEPMA0GA1UEBwwGTmV3dG9uMREw + DwYDVQQKDAhDeWJlckFyazEPMA0GA1UECwwGQ29uanVyMRAwDgYDVQQDDAdSb290 + IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7YPg2tpJYygd37RB + JQrAEnqtMctB01jSB4Snm3oQVz33z1OfLulTeJA56gwWN4OVm737zUJM1GET6fFC + ZIVsrhk8WsKeilnyE3FeVMmpbbteUt7DcTS2bpmk6p0MlaN8Y3EoDmVLKmcAoRXS + xLi8iOkClJPbpSbjQDg2ZnpyfEFBE+jhOWaFkgaSVt2tTUrAt3+F/3o6rRtsXplC + m2Fj/qK9x4Yw5sw098ztLNNomMCmhSD4ACn4jSZoq0HTH9QrZ9agXTpKkDOeAjMJ + O08T4XqW61o1YJRPjgIYqwtyCs5DHSzj4AmuYRSDRBgK/mIDDiQd9XL0VFW8CcKP + DnxSdQIDAQABo1AwTjAdBgNVHQ4EFgQU2/KbZMd7y7ZBfK884/4vB0AAg+AwHwYD + VR0jBBgwFoAU2/KbZMd7y7ZBfK884/4vB0AAg+AwDAYDVR0TBAUwAwEB/zANBgkq + hkiG9w0BAQsFAAOCAQEAr2UxJLH5j+3iez0mSwPY2m6QqK57mUWDzgMFHCtuohYT + saqhBXzsgHqFElw2WM2fQpeSxHqr0R1MrDz+qBg/tgFJ6AnVkW56v41oJb+kZsi/ + fhk7OhU9MhOqG9Wlnptp4QiLCCuKeDUUfQCnu15peR9vxQt0cLlzmr8MQdTuMvb9 + Vi7jey+Y5P04D8sqNP4BNUSRW8TwAKWkPJ4r3GybMsoCwqhb9+zAeYUj30TaxzKK + VSC0BRw+2QY8OllJPYIE3SCPK+v4SZp72KZ9ooSV+52ezmOCARuNWaNZKCbdPSme + DBHPd2jZXDVr5nrOEppAnma6VgmlRSN393j6GOiNIw== + -----END CERTIFICATE----- + EOF + } + let(:cert_subject_hash) { OpenSSL::X509::Certificate.new(cert).subject.hash.to_s(16) } + let(:symlink_path) { File.join(@cert_dir, "#{cert_subject_hash}.0") } + + context 'when target symlink does not already exist' do + it 'writes the certificate to the specified directory' do + allow(mock_discovery).to receive(:discover!).with(String) do + expect(File.exist?(symlink_path)).to be true + expect(File.read(symlink_path)).to eq(cert) + end + + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert + ) + end + + it 'cleans up the certificate after fetching discovery information' do + allow(mock_discovery).to receive(:discover!).with(String) + + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert + ) + + expect(File.exist?(symlink_path)).to be false + end + end + + context 'when target symlink already exists' do + before(:each) do + @tempfile = Tempfile.new("rspec.pem") + @tempfile.write("existing content") + @tempfile.flush + @tempfile.close + File.symlink(@tempfile, symlink_path) + end + + after(:each) do + @tempfile.unlink + File.unlink(symlink_path) + end + + it 'does not write the new certificate data to the specified directory' do + allow(mock_discovery).to receive(:discover!).with(String) do + expect(File.read(@tempfile.path)).to eq("existing content") + end + + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert + ) + end + + it 'maintains the certificate after fetching discovery information' do + allow(mock_discovery).to receive(:discover!).with(String) + + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert + ) + + expect(File.exist?(symlink_path)).to be true + expect(File.read(@tempfile.path)).to eq("existing content") + end + end + end + + context 'when invalid cert is provided' do + it 'raises an error' do + expect do + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: "invalid certificate" + ) + end.to raise_error(OpenSSL::X509::CertificateError) + end + end + end end describe 'OIDC client targeting Okta' do From be988e4ad6cc5f58ccc7ce00d20edd795439f0ed Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Thu, 24 Aug 2023 11:30:23 -0400 Subject: [PATCH 207/665] AuthnOIDC: optionally write temp certs before Authz Code callback (cherry picked from commit e6b20e954bb8e8a7998f088ab3acd258e2cd3140) --- .../authentication/authn_oidc/v2/client.rb | 40 +++++++ .../authn-oidc/v2/client_spec.rb | 105 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb index 896dc2730d..282723efd7 100644 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ b/app/domain/authentication/authn_oidc/v2/client.rb @@ -97,6 +97,46 @@ def callback(code:, nonce:, code_verifier: nil) decoded_id_token end + + # callback_with_temporary_cert wraps the callback method with commands + # to write & clean up a certificate to & from Conjur's default + # certificate store. + # + # The temporary certificate file name is "x.0", where x is the hash of + # the certificate subject name. If this file already exists in the + # default cert store, the original certificate is used. + # + # Unlike self.discover, which wraps a single ::OpenIDConnect method, + # callback_with_temporary_cert wraps the entire callback method, which + # includes multiple calls to the OIDC provider, including at least one + # discover! call. The temporary certs will apply to all required + # operations. + def callback_with_temporary_cert( + code:, + nonce:, + code_verifier: nil, + cert_dir: OpenSSL::X509::DEFAULT_CERT_DIR, + cert_string: nil + ) + c = -> { callback(code: code, nonce: nonce, code_verifier: code_verifier) } + + return c.call if cert_string.blank? + + cert = OpenSSL::X509::Certificate.new(cert_string) + symlink = File.join(cert_dir, "#{cert.subject.hash.to_s(16)}.0") + return c.call if File.exist?(symlink) + + Dir.mktmpdir do |tmp_dir| + tmp_file = File.join(tmp_dir, 'ca.pem') + File.write(tmp_file, cert_string) + File.symlink(tmp_file, symlink) + + c.call + ensure + File.unlink(symlink) if symlink.present? && File.symlink?(symlink) + end + end + def discovery_information(invalidate: false) @cache.fetch( "#{@authenticator.account}/#{@authenticator.service_id}/#{URI::Parser.new.escape(@authenticator.provider_uri)}", diff --git a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb index 33ca3c6800..a009224166 100644 --- a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb @@ -43,6 +43,96 @@ def client(config) end end end + + describe '.callback_with_temporary_cert', type: 'unit' do + context 'when credentials are valid', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-valid_oidc_credentials" do + context 'when no cert is required' do + it 'returns a valid JWT token' do + travel_to(Time.parse(config[:auth_time])) do + token = client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce] + ) + expect(token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken) + expect(token.raw_attributes['nonce']).to eq(config[:nonce]) + expect(token.raw_attributes['preferred_username']).to eq(config[:username]) + expect(token.aud).to eq(config[:client_id]) + end + end + end + + context 'when valid certificate is provided' do + let(:cert) { <<~EOF + -----BEGIN CERTIFICATE----- + MIIDqzCCApOgAwIBAgIJAP9vSJDyPfQdMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNV + BAYTAlVTMRYwFAYDVQQIDA1NYXNzYWNodXNldHRzMQ8wDQYDVQQHDAZOZXd0b24x + ETAPBgNVBAoMCEN5YmVyQXJrMQ8wDQYDVQQLDAZDb25qdXIxEDAOBgNVBAMMB1Jv + b3QgQ0EwHhcNMjMwODIzMjIyMjU1WhcNMzExMTA5MjIyMjU1WjBsMQswCQYDVQQG + EwJVUzEWMBQGA1UECAwNTWFzc2FjaHVzZXR0czEPMA0GA1UEBwwGTmV3dG9uMREw + DwYDVQQKDAhDeWJlckFyazEPMA0GA1UECwwGQ29uanVyMRAwDgYDVQQDDAdSb290 + IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7YPg2tpJYygd37RB + JQrAEnqtMctB01jSB4Snm3oQVz33z1OfLulTeJA56gwWN4OVm737zUJM1GET6fFC + ZIVsrhk8WsKeilnyE3FeVMmpbbteUt7DcTS2bpmk6p0MlaN8Y3EoDmVLKmcAoRXS + xLi8iOkClJPbpSbjQDg2ZnpyfEFBE+jhOWaFkgaSVt2tTUrAt3+F/3o6rRtsXplC + m2Fj/qK9x4Yw5sw098ztLNNomMCmhSD4ACn4jSZoq0HTH9QrZ9agXTpKkDOeAjMJ + O08T4XqW61o1YJRPjgIYqwtyCs5DHSzj4AmuYRSDRBgK/mIDDiQd9XL0VFW8CcKP + DnxSdQIDAQABo1AwTjAdBgNVHQ4EFgQU2/KbZMd7y7ZBfK884/4vB0AAg+AwHwYD + VR0jBBgwFoAU2/KbZMd7y7ZBfK884/4vB0AAg+AwDAYDVR0TBAUwAwEB/zANBgkq + hkiG9w0BAQsFAAOCAQEAr2UxJLH5j+3iez0mSwPY2m6QqK57mUWDzgMFHCtuohYT + saqhBXzsgHqFElw2WM2fQpeSxHqr0R1MrDz+qBg/tgFJ6AnVkW56v41oJb+kZsi/ + fhk7OhU9MhOqG9Wlnptp4QiLCCuKeDUUfQCnu15peR9vxQt0cLlzmr8MQdTuMvb9 + Vi7jey+Y5P04D8sqNP4BNUSRW8TwAKWkPJ4r3GybMsoCwqhb9+zAeYUj30TaxzKK + VSC0BRw+2QY8OllJPYIE3SCPK+v4SZp72KZ9ooSV+52ezmOCARuNWaNZKCbdPSme + DBHPd2jZXDVr5nrOEppAnma6VgmlRSN393j6GOiNIw== + -----END CERTIFICATE----- + EOF + } + let(:expected_hash) { OpenSSL::X509::Certificate.new(cert).subject.hash.to_s(16) } + let(:symlink_path) { File.join(OpenSSL::X509::DEFAULT_CERT_DIR, "#{expected_hash}.0") } + + context 'if a symlink for the certificate subject already exists' do + before(:each) do + @tempfile = Tempfile.new("rspec.pem") + File.symlink(@tempfile, symlink_path) + end + + after(:each) do + @tempfile.close + @tempfile.unlink + File.unlink(symlink_path) + end + + it 'maintains the certificate file' do + travel_to(Time.parse(config[:auth_time])) do + client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce], + cert_string: cert + ) + expect(File.exist?(symlink_path)).to be true + end + end + end + + context 'if the certificate does not already exist in the default cert store' do + it 'cleans up the temporary certificate file' do + travel_to(Time.parse(config[:auth_time])) do + expect(File.exist?(symlink_path)).to be false + client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce], + cert_string: cert + ) + expect(File.exist?(symlink_path)).to be false + end + end + end + end + end + end end shared_examples 'token retrieval failures' do |config| @@ -94,6 +184,21 @@ def client(config) end end end + + describe '.callback_with_temporary_cert', type: 'unit' do + context 'when invalid certificate is provided', vcr: "enabled" do + it 'raises an error' do + expect do + client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce], + cert_string: "invalid certificate" + ) + end.to raise_error(OpenSSL::X509::CertificateError) + end + end + end end shared_examples 'token validation failures' do |config| From 001528ead68c3f29e0d0f165c304208ecc8d5049 Mon Sep 17 00:00:00 2001 From: John ODonnell Date: Mon, 28 Aug 2023 14:17:31 -0400 Subject: [PATCH 208/665] Review updates: use CertUtils, handle hash collisions (cherry picked from commit bbde56c96f3f1b57169a2f95aadec88e1e585f07) --- .../authentication/authn_oidc/v2/client.rb | 87 ++- app/domain/errors.rb | 5 + .../authn-oidc/v2/client_spec.rb | 509 +++++++++++------- 3 files changed, 374 insertions(+), 227 deletions(-) diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb index 282723efd7..5bf6cadf4c 100644 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ b/app/domain/authentication/authn_oidc/v2/client.rb @@ -97,14 +97,13 @@ def callback(code:, nonce:, code_verifier: nil) decoded_id_token end - # callback_with_temporary_cert wraps the callback method with commands - # to write & clean up a certificate to & from Conjur's default - # certificate store. + # to write & clean up a given certificate or cert chain in a given + # directory. By default, Conjur's default cert store is used. # - # The temporary certificate file name is "x.0", where x is the hash of - # the certificate subject name. If this file already exists in the - # default cert store, the original certificate is used. + # The temporary certificate file name is "x.n", where x is the hash of + # the certificate subject name, and n is incrememnted from 0 in case of + # collision. # # Unlike self.discover, which wraps a single ::OpenIDConnect method, # callback_with_temporary_cert wraps the entire callback method, which @@ -122,18 +121,36 @@ def callback_with_temporary_cert( return c.call if cert_string.blank? - cert = OpenSSL::X509::Certificate.new(cert_string) - symlink = File.join(cert_dir, "#{cert.subject.hash.to_s(16)}.0") - return c.call if File.exist?(symlink) + begin + certs_a = ::Conjur::CertUtils.parse_certs(cert_string) + rescue OpenSSL::X509::CertificateError => e + raise Errors::Authentication::AuthnOidc::InvalidCertificate, e.message + end + raise Errors::Authentication::AuthnOidc::InvalidCertificate, "provided string does not contain a certificate" if certs_a.empty? + + symlink_a = [] Dir.mktmpdir do |tmp_dir| - tmp_file = File.join(tmp_dir, 'ca.pem') - File.write(tmp_file, cert_string) - File.symlink(tmp_file, symlink) + certs_a.each_with_index do |cert, idx| + tmp_file = File.join(tmp_dir, "conjur-oidc-client.#{idx}.pem") + File.write(tmp_file, cert.to_s) + + n = 0 + hash = cert.subject.hash.to_s(16) + while true + symlink = File.join(cert_dir, "#{hash}.#{n}") + break unless File.exist?(symlink) + + n += 1 + end + + File.symlink(tmp_file, symlink) + symlink_a << symlink + end c.call ensure - File.unlink(symlink) if symlink.present? && File.symlink?(symlink) + symlink_a.each{ |s| File.unlink(s) if s.present? && File.symlink?(s) } end end @@ -152,16 +169,16 @@ def discovery_information(invalidate: false) end # discover wraps ::OpenIDConnect::Discovery::Provider::Config.discover! - # with commands to write & clean up a certificate to & from the default - # Conjur container certificate store. + # with commands to write & clean up a given certificate or cert chain in + # a given directory. By default, Conjur's default cert store is used. # - # The temporary certificate file name is "x.0", where x is the hash of - # the certificate subject name. If this file already exists in the - # default cert store, the original certificate is used. + # The temporary certificate file name is "x.n", where x is the hash of + # the certificate subject name, and n is incremented from 0 in case of + # collision. # # discover is a class method, because there are a few contexts outside # this class where the underlying discover! method is used. Call it by - # running Authentication::AuthnOIDC::Client.discover(...). + # running Authentication::AuthnOIDC::V2::Client.discover(...). def self.discover( provider_uri:, discovery_configuration: ::OpenIDConnect::Discovery::Provider::Config, @@ -172,18 +189,36 @@ def self.discover( return d.call if cert_string.blank? - cert = OpenSSL::X509::Certificate.new(cert_string) - symlink = File.join(cert_dir, "#{cert.subject.hash.to_s(16)}.0") - return d.call if File.exist?(symlink) + begin + certs_a = ::Conjur::CertUtils.parse_certs(cert_string) + rescue OpenSSL::X509::CertificateError => e + raise Errors::Authentication::AuthnOidc::InvalidCertificate, e.message + end + raise Errors::Authentication::AuthnOidc::InvalidCertificate, "provided string does not contain a certificate" if certs_a.empty? + + symlink_a = [] Dir.mktmpdir do |tmp_dir| - tmp_file = File.join(tmp_dir, 'ca.pem') - File.write(tmp_file, cert_string) - File.symlink(tmp_file, symlink) + certs_a.each_with_index do |cert, idx| + tmp_file = File.join(tmp_dir, "conjur-oidc-client.#{idx}.pem") + File.write(tmp_file, cert.to_s) + + n = 0 + hash = cert.subject.hash.to_s(16) + while true + symlink = File.join(cert_dir, "#{hash}.#{n}") + break unless File.exist?(symlink) + + n += 1 + end + + File.symlink(tmp_file, symlink) + symlink_a << symlink + end d.call ensure - File.unlink(symlink) if symlink.present? && File.symlink?(symlink) + symlink_a.each{ |s| File.unlink(s) if s.present? && File.symlink?(s) } end end end diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 6593562702..3ffa1418cb 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -275,6 +275,11 @@ module AuthnOidc msg: "Access Token retrieval failure: '{0-error}'", code: "CONJ00133E" ) + + InvalidCertificate = ::Util::TrackableErrorClass.new( + msg: "Invalid certificate: {0-message}", + code: "CONJ00135E" + ) end module AuthnIam diff --git a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb index a009224166..4dc18b8ff3 100644 --- a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb @@ -88,8 +88,21 @@ def client(config) -----END CERTIFICATE----- EOF } - let(:expected_hash) { OpenSSL::X509::Certificate.new(cert).subject.hash.to_s(16) } - let(:symlink_path) { File.join(OpenSSL::X509::DEFAULT_CERT_DIR, "#{expected_hash}.0") } + let(:cert_subject_hash) { OpenSSL::X509::Certificate.new(cert).subject.hash.to_s(16) } + let(:symlink_path) { File.join(OpenSSL::X509::DEFAULT_CERT_DIR, "#{cert_subject_hash}.0") } + + it 'cleans up the temporary certificate file' do + travel_to(Time.parse(config[:auth_time])) do + expect(File.exist?(symlink_path)).to be false + client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce], + cert_string: cert + ) + expect(File.exist?(symlink_path)).to be false + end + end context 'if a symlink for the certificate subject already exists' do before(:each) do @@ -98,8 +111,7 @@ def client(config) end after(:each) do - @tempfile.close - @tempfile.unlink + @tempfile.close! File.unlink(symlink_path) end @@ -115,21 +127,6 @@ def client(config) end end end - - context 'if the certificate does not already exist in the default cert store' do - it 'cleans up the temporary certificate file' do - travel_to(Time.parse(config[:auth_time])) do - expect(File.exist?(symlink_path)).to be false - client(config).callback_with_temporary_cert( - code: config[:code], - code_verifier: config[:code_verifier], - nonce: config[:nonce], - cert_string: cert - ) - expect(File.exist?(symlink_path)).to be false - end - end - end end end end @@ -168,7 +165,7 @@ def client(config) ) end end - + context 'when code has expired', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-expired_code-valid_oidc_credentials" do it 'raise an exception' do expect do @@ -186,16 +183,40 @@ def client(config) end describe '.callback_with_temporary_cert', type: 'unit' do - context 'when invalid certificate is provided', vcr: "enabled" do - it 'raises an error' do - expect do - client(config).callback_with_temporary_cert( - code: config[:code], - code_verifier: config[:code_verifier], - nonce: config[:nonce], - cert_string: "invalid certificate" - ) - end.to raise_error(OpenSSL::X509::CertificateError) + context 'when invalid cert is provided', vcr: 'enabled' do + context 'string does not contain a certificate' do + let(:cert) { "does not contain a certificate" } + + it 'raises an error' do + expect do + client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce], + cert_string: cert + ) + end.to raise_error(Errors::Authentication::AuthnOidc::InvalidCertificate) + end + end + + context 'string contains malformed certificate' do + let(:cert) { <<~EOF + -----BEGIN CERTIFICATE----- + hello future contributor :) + -----END CERTIFICATE----- + EOF + } + + it 'raises an error' do + expect do + client(config).callback_with_temporary_cert( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce], + cert_string: cert + ) + end.to raise_error(Errors::Authentication::AuthnOidc::InvalidCertificate) + end end end end @@ -291,232 +312,318 @@ def client(config) end end end + end + + describe 'OIDC client targeting Okta' do + config = { + provider_uri: 'https://dev-92899796.okta.com/oauth2/default', + host: 'dev-92899796.okta.com', + client_id: '0oa3w3xig6rHiu9yT5d7', + client_secret: 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj', + service_id: 'okta-2', + expected_authz: '/oauth2/default/v1/authorize', + expected_token: '/oauth2/default/v1/token', + expected_userinfo: '/oauth2/default/v1/userinfo', + expected_keys: '/oauth2/default/v1/keys', + auth_time: '2022-09-30 17:02:17 +0000', + code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw', + code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', + nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d', + username: 'test.user3@mycompany.com' + } + + include_examples 'client setup', config + include_examples 'happy path', config + include_examples 'token retrieval failures', config + include_examples 'token validation failures', config + end + + describe 'OIDC client targeting Identity' do + config = { + provider_uri: 'https://redacted-host/redacted_app/', + host: 'redacted-host', + client_id: 'redacted-id', + client_secret: 'redacted-secret', + service_id: 'identity', + expected_authz: '/OAuth2/Authorize/redacted_app', + expected_token: '/OAuth2/Token/redacted_app', + expected_userinfo: '/OAuth2/UserInfo/redacted_app', + expected_keys: '/OAuth2/Keys/redacted_app', + auth_time: '2023-4-10 18:00:00 +0000', + code: 'puPaKJOr_E25STHsM_-rOo3fgJBz2TKVNsi8GzBvwS41', + code_verifier: '9625bb8881c08de323bb17242d6b3552e50aec0e999e15c66a', + nonce: 'f1daadf8108eaf6ccf3295fd679acc5218f776d1aaaa3d270a' + } + + include_examples 'client setup', config + include_examples 'token retrieval failures', config + end + + describe '.discover', type: 'unit' do + let(:target) { Authentication::AuthnOidc::V2::Client } + let(:provider_uri) { "https://oidcprovider.com" } + let(:mock_discovery) { double("Mock Discovery Config") } + let(:mock_response) { "Mock Discovery Response" } + + before(:each) do + @cert_dir = Dir.mktmpdir + end - describe '.discover', type: 'unit' do - let(:target) { Authentication::AuthnOidc::V2::Client } - let(:provider_uri) { "https://oidcprovider.com" } - let(:mock_discovery) { double("Mock Discovery Config") } - let(:mock_response) { "Mock Discovery Response" } + after(:each) do + FileUtils.remove_entry @cert_dir + end + + context 'when cert is not provided' do + it 'does not write the certificate' do + allow(mock_discovery).to receive(:discover!).with(String) do + expect(Dir.entries(@cert_dir).select do |entry| + entry unless [".", ".."].include?(entry) + end).to be_empty + end - before(:each) do - @cert_dir = Dir.mktmpdir + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: "" + ) end - after(:each) do - FileUtils.remove_entry @cert_dir + it 'returns the discovery response' do + allow(mock_discovery).to receive(:discover!).with(String).and_return( + mock_response + ) + + expect(target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: "" + )).to eq(mock_response) end + end - context 'when no cert is required' do - context 'when credentials are valid', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/discovery_endpoint-valid_oidc_credentials" do - it 'endpoint return valid data' do - resp = target.discover(provider_uri: config[:provider_uri]) + context 'when valid cert is provided' do + let(:cert) { <<~EOF + -----BEGIN CERTIFICATE----- + MIIDqzCCApOgAwIBAgIJAP9vSJDyPfQdMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNV + BAYTAlVTMRYwFAYDVQQIDA1NYXNzYWNodXNldHRzMQ8wDQYDVQQHDAZOZXd0b24x + ETAPBgNVBAoMCEN5YmVyQXJrMQ8wDQYDVQQLDAZDb25qdXIxEDAOBgNVBAMMB1Jv + b3QgQ0EwHhcNMjMwODIzMjIyMjU1WhcNMzExMTA5MjIyMjU1WjBsMQswCQYDVQQG + EwJVUzEWMBQGA1UECAwNTWFzc2FjaHVzZXR0czEPMA0GA1UEBwwGTmV3dG9uMREw + DwYDVQQKDAhDeWJlckFyazEPMA0GA1UECwwGQ29uanVyMRAwDgYDVQQDDAdSb290 + IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7YPg2tpJYygd37RB + JQrAEnqtMctB01jSB4Snm3oQVz33z1OfLulTeJA56gwWN4OVm737zUJM1GET6fFC + ZIVsrhk8WsKeilnyE3FeVMmpbbteUt7DcTS2bpmk6p0MlaN8Y3EoDmVLKmcAoRXS + xLi8iOkClJPbpSbjQDg2ZnpyfEFBE+jhOWaFkgaSVt2tTUrAt3+F/3o6rRtsXplC + m2Fj/qK9x4Yw5sw098ztLNNomMCmhSD4ACn4jSZoq0HTH9QrZ9agXTpKkDOeAjMJ + O08T4XqW61o1YJRPjgIYqwtyCs5DHSzj4AmuYRSDRBgK/mIDDiQd9XL0VFW8CcKP + DnxSdQIDAQABo1AwTjAdBgNVHQ4EFgQU2/KbZMd7y7ZBfK884/4vB0AAg+AwHwYD + VR0jBBgwFoAU2/KbZMd7y7ZBfK884/4vB0AAg+AwDAYDVR0TBAUwAwEB/zANBgkq + hkiG9w0BAQsFAAOCAQEAr2UxJLH5j+3iez0mSwPY2m6QqK57mUWDzgMFHCtuohYT + saqhBXzsgHqFElw2WM2fQpeSxHqr0R1MrDz+qBg/tgFJ6AnVkW56v41oJb+kZsi/ + fhk7OhU9MhOqG9Wlnptp4QiLCCuKeDUUfQCnu15peR9vxQt0cLlzmr8MQdTuMvb9 + Vi7jey+Y5P04D8sqNP4BNUSRW8TwAKWkPJ4r3GybMsoCwqhb9+zAeYUj30TaxzKK + VSC0BRw+2QY8OllJPYIE3SCPK+v4SZp72KZ9ooSV+52ezmOCARuNWaNZKCbdPSme + DBHPd2jZXDVr5nrOEppAnma6VgmlRSN393j6GOiNIw== + -----END CERTIFICATE----- + EOF + } + let(:cert_subject_hash) { OpenSSL::X509::Certificate.new(cert).subject.hash.to_s(16) } + let(:symlink_path) { File.join(@cert_dir, "#{cert_subject_hash}.0") } + + it 'writes the certificate to the specified directory' do + allow(mock_discovery).to receive(:discover!).with(String) do + expect(File.exist?(symlink_path)).to be true + expect(File.read(symlink_path)).to eq(cert) + end - expect(resp.authorization_endpoint).to eq("https://#{config[:host]}#{config[:expected_authz]}") - expect(resp.token_endpoint).to eq("https://#{config[:host]}#{config[:expected_token]}") - expect(resp.userinfo_endpoint).to eq("https://#{config[:host]}#{config[:expected_userinfo]}") - expect(resp.jwks_uri).to eq("https://#{config[:host]}#{config[:expected_keys]}") - end + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert + ) + end + + it 'cleans up the certificate after fetching discovery information' do + allow(mock_discovery).to receive(:discover!).with(String) + + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert + ) + + expect(File.exist?(symlink_path)).to be false + end + + context 'when target symlink already exists' do + before(:each) do + @tempfile = Tempfile.new("rspec.pem") + File.symlink(@tempfile, symlink_path) end - context 'when provider URI is invalid', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/discovery_endpoint-invalid_oidc_provider" do - it 'raises an error' do - expect do - target.discover(provider_uri: "https://foo.bar.com") - end.to raise_error( - OpenIDConnect::Discovery::DiscoveryFailed - ) - end + after(:each) do + @tempfile.close! + File.unlink(symlink_path) end - end - context 'when cert is not provided' do - it 'does not write the certificate' do + it 'writes the certificate to the specified directory with incremented name' do allow(mock_discovery).to receive(:discover!).with(String) do - expect(Dir.entries(@cert_dir).select do |entry| - entry unless [".", ".."].include?(entry) - end).to be_empty + expect(File.exist?(symlink_path)).to be true + + incremented = File.join(@cert_dir, "#{cert_subject_hash}.1") + expect(File.exist?(incremented)) + expect(File.read(incremented)).to eq(cert) end target.discover( provider_uri: provider_uri, discovery_configuration: mock_discovery, cert_dir: @cert_dir, - cert_string: "" + cert_string: cert ) end - it 'returns the discovery response' do - allow(mock_discovery).to receive(:discover!).with(String).and_return( - mock_response - ) + it 'maintains the original while cleaning up the created cert' do + allow(mock_discovery).to receive(:discover!).with(String) - expect(target.discover( + target.discover( provider_uri: provider_uri, discovery_configuration: mock_discovery, cert_dir: @cert_dir, - cert_string: "" - )).to eq(mock_response) + cert_string: cert + ) + + expect(File.exist?(symlink_path)).to be true + expect(File.exist?(File.join(@cert_dir, "#{cert_subject_hash}.1"))).to be false end end + end - context 'when valid cert is provided' do - let(:cert) { <<~EOF - -----BEGIN CERTIFICATE----- - MIIDqzCCApOgAwIBAgIJAP9vSJDyPfQdMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNV - BAYTAlVTMRYwFAYDVQQIDA1NYXNzYWNodXNldHRzMQ8wDQYDVQQHDAZOZXd0b24x - ETAPBgNVBAoMCEN5YmVyQXJrMQ8wDQYDVQQLDAZDb25qdXIxEDAOBgNVBAMMB1Jv - b3QgQ0EwHhcNMjMwODIzMjIyMjU1WhcNMzExMTA5MjIyMjU1WjBsMQswCQYDVQQG - EwJVUzEWMBQGA1UECAwNTWFzc2FjaHVzZXR0czEPMA0GA1UEBwwGTmV3dG9uMREw - DwYDVQQKDAhDeWJlckFyazEPMA0GA1UECwwGQ29uanVyMRAwDgYDVQQDDAdSb290 - IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7YPg2tpJYygd37RB - JQrAEnqtMctB01jSB4Snm3oQVz33z1OfLulTeJA56gwWN4OVm737zUJM1GET6fFC - ZIVsrhk8WsKeilnyE3FeVMmpbbteUt7DcTS2bpmk6p0MlaN8Y3EoDmVLKmcAoRXS - xLi8iOkClJPbpSbjQDg2ZnpyfEFBE+jhOWaFkgaSVt2tTUrAt3+F/3o6rRtsXplC - m2Fj/qK9x4Yw5sw098ztLNNomMCmhSD4ACn4jSZoq0HTH9QrZ9agXTpKkDOeAjMJ - O08T4XqW61o1YJRPjgIYqwtyCs5DHSzj4AmuYRSDRBgK/mIDDiQd9XL0VFW8CcKP - DnxSdQIDAQABo1AwTjAdBgNVHQ4EFgQU2/KbZMd7y7ZBfK884/4vB0AAg+AwHwYD - VR0jBBgwFoAU2/KbZMd7y7ZBfK884/4vB0AAg+AwDAYDVR0TBAUwAwEB/zANBgkq - hkiG9w0BAQsFAAOCAQEAr2UxJLH5j+3iez0mSwPY2m6QqK57mUWDzgMFHCtuohYT - saqhBXzsgHqFElw2WM2fQpeSxHqr0R1MrDz+qBg/tgFJ6AnVkW56v41oJb+kZsi/ - fhk7OhU9MhOqG9Wlnptp4QiLCCuKeDUUfQCnu15peR9vxQt0cLlzmr8MQdTuMvb9 - Vi7jey+Y5P04D8sqNP4BNUSRW8TwAKWkPJ4r3GybMsoCwqhb9+zAeYUj30TaxzKK - VSC0BRw+2QY8OllJPYIE3SCPK+v4SZp72KZ9ooSV+52ezmOCARuNWaNZKCbdPSme - DBHPd2jZXDVr5nrOEppAnma6VgmlRSN393j6GOiNIw== - -----END CERTIFICATE----- - EOF - } - let(:cert_subject_hash) { OpenSSL::X509::Certificate.new(cert).subject.hash.to_s(16) } - let(:symlink_path) { File.join(@cert_dir, "#{cert_subject_hash}.0") } - - context 'when target symlink does not already exist' do - it 'writes the certificate to the specified directory' do - allow(mock_discovery).to receive(:discover!).with(String) do - expect(File.exist?(symlink_path)).to be true - expect(File.read(symlink_path)).to eq(cert) - end - - target.discover( - provider_uri: provider_uri, - discovery_configuration: mock_discovery, - cert_dir: @cert_dir, - cert_string: cert - ) - end - - it 'cleans up the certificate after fetching discovery information' do - allow(mock_discovery).to receive(:discover!).with(String) - - target.discover( - provider_uri: provider_uri, - discovery_configuration: mock_discovery, - cert_dir: @cert_dir, - cert_string: cert - ) - - expect(File.exist?(symlink_path)).to be false + context 'when valid cert chain is provided' do + let(:client_cert) { <<~EOF + -----BEGIN CERTIFICATE----- + MIIC6zCCAlQCAQEwDQYJKoZIhvcNAQELBQAwdjELMAkGA1UEBhMCVVMxFjAUBgNV + BAgMDU1hc3NhY2h1c2V0dHMxDzANBgNVBAcMBk5ld3RvbjERMA8GA1UECgwIQ3li + ZXJBcmsxDzANBgNVBAsMBkNvbmp1cjEaMBgGA1UEAwwRVW5pdCBUZXN0IFJvb3Qg + Q0EwHhcNMjMwODI1MTgxMzM1WhcNMzMwODIyMTgxMzM1WjCBgTELMAkGA1UEBhMC + VVMxFjAUBgNVBAgMDU1hc3NhY2h1c2V0dHMxDzANBgNVBAcMBk5ld3RvbjERMA8G + A1UECgwIQ3liZXJBcmsxDzANBgNVBAsMBkNvbmp1cjElMCMGA1UEAwwcVW5pdCBU + ZXN0IENsaWVudCBDZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC + AQoCggEBAMPeuIWmjgF381jSV2/lgS2tZYkD53ukM9nlnIEI3N4QZ46aD0+tcet+ + 2gZ5+TdwceZMc8R8krSuA25Kojn2tvKInyrmWWbIGV2JA+iBeRiMSjbbh4keAWYW + /HKawCRfdxmYheBEFbbKtFcsKxuIqEmFEdwG7TeJx6wr2zIayenC7I8HzAk7LQSW + pJb6Fv/gpbagNmnoITeIC58s+ibF77OVk5XW0hFkyO/La46R+WhATp8ayYmXpwWT + yVemxs4P60N5AK8NvmvRPxuQfOSAP154W0WYD5FtKUcPP3CdOQEZhGjWGiScZ7mr + 6aLYuac4gS7b/kOC+Fzqw3NNY7vUs6MCAwEAATANBgkqhkiG9w0BAQsFAAOBgQB5 + O4a3Qs5zPO2cGW4fX92nmB9jj1sxik+3hVV/aTHNUfAYJ0aula+kKqghbVlrlsAm + 6Oqdw3WCoBkUjqUQqqPlLqmmxA/AW+izqLzvaZnBCGyHiFGYUFhMilk9mfE/m63v + EhjKF017l50ptBaUYiD1W9IXGWZJ9b1nxnr/S+CXCQ== + -----END CERTIFICATE----- + EOF + } + let(:client_hash) { OpenSSL::X509::Certificate.new(client_cert).subject.hash.to_s(16) } + let(:ca_cert) { <<~EOF + -----BEGIN CERTIFICATE----- + MIICYzCCAcwCCQCtimZfxnGkRTANBgkqhkiG9w0BAQsFADB2MQswCQYDVQQGEwJV + UzEWMBQGA1UECAwNTWFzc2FjaHVzZXR0czEPMA0GA1UEBwwGTmV3dG9uMREwDwYD + VQQKDAhDeWJlckFyazEPMA0GA1UECwwGQ29uanVyMRowGAYDVQQDDBFVbml0IFRl + c3QgUm9vdCBDQTAeFw0yMzA4MjUxODA5MjJaFw0zMzA4MjIxODA5MjJaMHYxCzAJ + BgNVBAYTAlVTMRYwFAYDVQQIDA1NYXNzYWNodXNldHRzMQ8wDQYDVQQHDAZOZXd0 + b24xETAPBgNVBAoMCEN5YmVyQXJrMQ8wDQYDVQQLDAZDb25qdXIxGjAYBgNVBAMM + EVVuaXQgVGVzdCBSb290IENBMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQD0 + r78pu6hJZTKXR4qLHNbZ8sM4IWrTBRerBumf5Qjq3LmNhvMCXYee1Z9YmHOh5UrA + JbONCM3ASt1INbf3pD52JJEEWA8udEvGhONsnrjuXI2DoBg/W/4rye9p6+SagOSF + O9oLUIczL4XxIgE1CXi89uwCwn0BxjLnaLraMxvbgQIDAQABMA0GCSqGSIb3DQEB + CwUAA4GBANUZ4iQLe83CIb4DV73a+OUwZ19YJ0DCMvXDMWW0CTwVv4DhxM8ZkTpu + 1FQ/uXrA9FP/kulYAMLqo8RkYiE+u64Jbs/vWebupyV89dh5sFEsp0PafQa415C6 + h1Tg+4C+eSkQIEIGVm8tLVG8JQL4sweo/gQGdzcxfCSfPZHqInzD + -----END CERTIFICATE----- + EOF + } + let(:ca_hash) { OpenSSL::X509::Certificate.new(ca_cert).subject.hash.to_s(16) } + let(:cert_strings) { [ client_cert, ca_cert ] } + let(:hashes) { [ client_hash, ca_hash ] } + let(:cert_chain) { "#{client_cert}\n#{ca_cert}" } + + it 'writes all certificates to the specified directory' do + allow(mock_discovery).to receive(:discover!).with(String) do + hashes.each_with_index do |hash, i| + cert_path = File.join(@cert_dir, "#{hash}.0") + expect(File.exist?(cert_path)).to be true + expect(File.symlink?(cert_path)).to be true + expect(File.read(cert_path)).to eq(cert_strings[i]) end end - context 'when target symlink already exists' do - before(:each) do - @tempfile = Tempfile.new("rspec.pem") - @tempfile.write("existing content") - @tempfile.flush - @tempfile.close - File.symlink(@tempfile, symlink_path) - end + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert_chain + ) + end - after(:each) do - @tempfile.unlink - File.unlink(symlink_path) - end + it 'cleans up all certificates after fetching discovery information' do + allow(mock_discovery).to receive(:discover!).with(String) - it 'does not write the new certificate data to the specified directory' do - allow(mock_discovery).to receive(:discover!).with(String) do - expect(File.read(@tempfile.path)).to eq("existing content") - end + target.discover( + provider_uri: provider_uri, + discovery_configuration: mock_discovery, + cert_dir: @cert_dir, + cert_string: cert_chain + ) - target.discover( - provider_uri: provider_uri, - discovery_configuration: mock_discovery, - cert_dir: @cert_dir, - cert_string: cert - ) - end + hashes.each do |hash| + cert_path = File.join(@cert_dir, "#{hash}.0") + expect(File.exist?(cert_path)).to be false + end + end + end - it 'maintains the certificate after fetching discovery information' do - allow(mock_discovery).to receive(:discover!).with(String) + context 'when invalid cert is provided' do + context 'string does not contain a certificate' do + let(:cert) { "does not contain a certificate" } + it 'raises an error' do + expect do target.discover( provider_uri: provider_uri, discovery_configuration: mock_discovery, cert_dir: @cert_dir, cert_string: cert ) - - expect(File.exist?(symlink_path)).to be true - expect(File.read(@tempfile.path)).to eq("existing content") + end.to raise_error(Errors::Authentication::AuthnOidc::InvalidCertificate) do |e| + expect(e.message).to include("provided string does not contain a certificate") end end end - context 'when invalid cert is provided' do + context 'string contains malformed certificate' do + let(:cert) { <<~EOF + -----BEGIN CERTIFICATE----- + hellofuturecontributor:) + -----END CERTIFICATE----- + EOF + } + it 'raises an error' do expect do target.discover( provider_uri: provider_uri, discovery_configuration: mock_discovery, cert_dir: @cert_dir, - cert_string: "invalid certificate" + cert_string: cert ) - end.to raise_error(OpenSSL::X509::CertificateError) + end.to raise_error(Errors::Authentication::AuthnOidc::InvalidCertificate) do |e| + expect(e.message).to include(cert) + expect(e.message).to include("nested asn1 error") + end end end end end - - describe 'OIDC client targeting Okta' do - config = { - provider_uri: 'https://dev-92899796.okta.com/oauth2/default', - host: 'dev-92899796.okta.com', - client_id: '0oa3w3xig6rHiu9yT5d7', - client_secret: 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj', - service_id: 'okta-2', - expected_authz: '/oauth2/default/v1/authorize', - expected_token: '/oauth2/default/v1/token', - expected_userinfo: '/oauth2/default/v1/userinfo', - expected_keys: '/oauth2/default/v1/keys', - auth_time: '2022-09-30 17:02:17 +0000', - code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw', - code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', - nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d', - username: 'test.user3@mycompany.com' - } - - include_examples 'client setup', config - include_examples 'happy path', config - include_examples 'token retrieval failures', config - include_examples 'token validation failures', config - end - - describe 'OIDC client targeting Identity' do - config = { - provider_uri: 'https://redacted-host/redacted_app/', - host: 'redacted-host', - client_id: 'redacted-id', - client_secret: 'redacted-secret', - service_id: 'identity', - expected_authz: '/OAuth2/Authorize/redacted_app', - expected_token: '/OAuth2/Token/redacted_app', - expected_userinfo: '/OAuth2/UserInfo/redacted_app', - expected_keys: '/OAuth2/Keys/redacted_app', - auth_time: '2023-4-10 18:00:00 +0000', - code: 'puPaKJOr_E25STHsM_-rOo3fgJBz2TKVNsi8GzBvwS41', - code_verifier: '9625bb8881c08de323bb17242d6b3552e50aec0e999e15c66a', - nonce: 'f1daadf8108eaf6ccf3295fd679acc5218f776d1aaaa3d270a' - } - - include_examples 'client setup', config - include_examples 'token retrieval failures', config - end end From a704cbd8485f09457035f5c1c8b24f465a4219e8 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Tue, 29 Aug 2023 14:41:04 -0600 Subject: [PATCH 209/665] Add optional 'ca-cert' config to authn-oidc (cherry picked from commit 44dbf5d8b121e8306b6575772e0004cdb3adcb95) --- .../authn_azure/authenticator.rb | 3 +- .../authn_azure/validate_status.rb | 3 +- .../authn_gcp/update_authenticator_input.rb | 3 +- .../authn_gcp/validate_status.rb | 3 +- .../fetch_provider_uri_signing_key.rb | 3 +- .../authn_oidc/authenticator.rb | 3 +- ...pdate_input_with_username_from_id_token.rb | 12 +++- .../v2/data_objects/authenticator.rb | 9 ++- .../authn_oidc/validate_status.rb | 15 +++-- .../o_auth/discover_identity_provider.rb | 2 +- .../o_auth/fetch_provider_keys.rb | 5 +- .../o_auth/verify_and_decode_token.rb | 5 +- .../util/fetch_authenticator_secrets.rb | 26 +++++--- app/domain/conjur/fetch_optional_secrets.rb | 33 ++++++++++ ci/oauth/keycloak/fetch_certificate | 1 - ci/test_suites/authenticators_oidc/policy.yml | 3 + .../authenticators/authn-oidc/keycloak2.yml | 8 +++ dev/start | 7 +++ ...authenticator.rb => authenticator_spec.rb} | 17 +++++- .../authn-oidc/validate_status_spec.rb | 2 +- .../o_auth/discover_identity_provider_spec.rb | 9 ++- .../conjur/fetch_optional_secrets_spec.rb | 61 +++++++++++++++++++ 22 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 app/domain/conjur/fetch_optional_secrets.rb rename spec/app/domain/authentication/authn-oidc/v2/data_objects/{authenticator.rb => authenticator_spec.rb} (84%) create mode 100644 spec/app/domain/conjur/fetch_optional_secrets_spec.rb diff --git a/app/domain/authentication/authn_azure/authenticator.rb b/app/domain/authentication/authn_azure/authenticator.rb index f841644c30..1070ae00a8 100644 --- a/app/domain/authentication/authn_azure/authenticator.rb +++ b/app/domain/authentication/authn_azure/authenticator.rb @@ -36,7 +36,8 @@ def decoded_token claims_to_verify: { verify_iss: true, iss: provider_uri - } + }, + ca_cert: nil ), logger: @logger ) diff --git a/app/domain/authentication/authn_azure/validate_status.rb b/app/domain/authentication/authn_azure/validate_status.rb index 5aa33d6e0c..f97b2e4640 100644 --- a/app/domain/authentication/authn_azure/validate_status.rb +++ b/app/domain/authentication/authn_azure/validate_status.rb @@ -34,7 +34,8 @@ def required_variable_names def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: provider_uri + provider_uri: provider_uri, + ca_cert: nil ) end diff --git a/app/domain/authentication/authn_gcp/update_authenticator_input.rb b/app/domain/authentication/authn_gcp/update_authenticator_input.rb index 524f29b2d6..10b403f71d 100644 --- a/app/domain/authentication/authn_gcp/update_authenticator_input.rb +++ b/app/domain/authentication/authn_gcp/update_authenticator_input.rb @@ -50,7 +50,8 @@ def decoded_token iss: PROVIDER_URI, verify_iat: true, verify_expiration: true - } + }, + ca_cert: nil ), logger: @logger ) diff --git a/app/domain/authentication/authn_gcp/validate_status.rb b/app/domain/authentication/authn_gcp/validate_status.rb index 3bf517cb93..26afd56c13 100644 --- a/app/domain/authentication/authn_gcp/validate_status.rb +++ b/app/domain/authentication/authn_gcp/validate_status.rb @@ -15,7 +15,8 @@ def call def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: PROVIDER_URI + provider_uri: PROVIDER_URI, + ca_cert: nil ) end end diff --git a/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb b/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb index 9f2f35675a..ed7a7df71c 100644 --- a/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb +++ b/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb @@ -39,7 +39,8 @@ def discover_provider def discovered_provider @discovered_provider ||= @discover_identity_provider.call( - provider_uri: @provider_uri + provider_uri: @provider_uri, + ca_cert: nil ) end diff --git a/app/domain/authentication/authn_oidc/authenticator.rb b/app/domain/authentication/authn_oidc/authenticator.rb index 9f765b6ee4..17cfcccc06 100644 --- a/app/domain/authentication/authn_oidc/authenticator.rb +++ b/app/domain/authentication/authn_oidc/authenticator.rb @@ -39,7 +39,8 @@ def status(authenticator_status_input:) # If successful, validate the new set of required variables if authenticator.present? Authentication::AuthnOidc::ValidateStatus.new( - required_variable_names: %w[provider-uri client-id client-secret claim-mapping] + required_variable_names: %w[provider-uri client-id client-secret claim-mapping], + optional_variable_names: %w[ca-cert] ).( account: authenticator_status_input.account, service_id: authenticator_status_input.service_id diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index 5e98e78a42..99cc00fb1d 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -3,7 +3,6 @@ module AuthnOidc UpdateInputWithUsernameFromIdToken ||= CommandClass.new( dependencies: { - fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new, validate_account_exists: ::Authentication::Security::ValidateAccountExists.new, verify_and_decode_token: ::Authentication::OAuth::VerifyAndDecodeToken.new, logger: Rails.logger @@ -40,7 +39,8 @@ def verify_and_decode_token @decoded_token = @verify_and_decode_token.( provider_uri: oidc_authenticator_secrets["provider-uri"], token_jwt: decoded_credentials["id_token"], - claims_to_verify: {} # We don't verify any claims + claims_to_verify: {}, # We don't verify any claims + ca_cert: oidc_authenticator_secrets["ca-cert"] ) end @@ -86,7 +86,9 @@ def token_from_body end def oidc_authenticator_secrets - @oidc_authenticator_secrets ||= @fetch_authenticator_secrets.( + @oidc_authenticator_secrets ||= Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: optional_variable_names + ).( service_id: service_id, conjur_account: account, authenticator_name: authenticator_name, @@ -98,6 +100,10 @@ def required_variable_names @required_variable_names ||= %w[provider-uri id-token-user-property] end + def optional_variable_names + @optional_variable_names ||= %w[ca-cert] + end + def validate_conjur_username if conjur_username.empty? raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( diff --git a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb index 15f4bdffe5..b542b1fb7d 100644 --- a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb +++ b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb @@ -5,7 +5,7 @@ module DataObjects class Authenticator REQUIRED_VARIABLES = %i[provider_uri client_id client_secret claim_mapping].freeze - OPTIONAL_VARIABLES = %i[redirect_uri response_type provider_scope name token_ttl].freeze + OPTIONAL_VARIABLES = %i[redirect_uri response_type provider_scope name token_ttl ca_cert].freeze attr_reader( :provider_uri, @@ -15,7 +15,8 @@ class Authenticator :account, :service_id, :redirect_uri, - :response_type + :response_type, + :ca_cert ) def initialize( @@ -29,7 +30,8 @@ def initialize( name: nil, response_type: 'code', provider_scope: nil, - token_ttl: 'PT60M' + token_ttl: 'PT60M', + ca_cert: nil ) @account = account @provider_uri = provider_uri @@ -42,6 +44,7 @@ def initialize( @provider_scope = provider_scope @redirect_uri = redirect_uri @token_ttl = token_ttl + @ca_cert = ca_cert end def scope diff --git a/app/domain/authentication/authn_oidc/validate_status.rb b/app/domain/authentication/authn_oidc/validate_status.rb index eb24824df1..3d005a4b15 100644 --- a/app/domain/authentication/authn_oidc/validate_status.rb +++ b/app/domain/authentication/authn_oidc/validate_status.rb @@ -3,9 +3,9 @@ module AuthnOidc ValidateStatus = CommandClass.new( dependencies: { - fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new, discover_identity_provider: Authentication::OAuth::DiscoverIdentityProvider.new, - required_variable_names: %w[provider-uri id-token-user-property] + required_variable_names: %w[provider-uri id-token-user-property], + optional_variable_names: %w[ca-cert] }, inputs: %i[account service_id] ) do @@ -26,7 +26,9 @@ def validate_secrets end def oidc_authenticator_secrets - @oidc_authenticator_secrets ||= @fetch_authenticator_secrets.( + @oidc_authenticator_secrets ||= Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: @optional_variable_names + ).( service_id: @service_id, conjur_account: @account, authenticator_name: "authn-oidc", @@ -36,13 +38,18 @@ def oidc_authenticator_secrets def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: provider_uri + provider_uri: provider_uri, + ca_cert: ca_cert ) end def provider_uri @oidc_authenticator_secrets["provider-uri"] end + + def ca_cert + @oidc_authenticator_secrets["ca-cert"] + end end end end diff --git a/app/domain/authentication/o_auth/discover_identity_provider.rb b/app/domain/authentication/o_auth/discover_identity_provider.rb index cabd01ba64..9a78d26e6c 100644 --- a/app/domain/authentication/o_auth/discover_identity_provider.rb +++ b/app/domain/authentication/o_auth/discover_identity_provider.rb @@ -6,7 +6,7 @@ module OAuth logger: Rails.logger, open_id_discovery_service: OpenIDConnect::Discovery::Provider::Config }, - inputs: %i[provider_uri] + inputs: %i[provider_uri ca_cert] ) do def call log_provider_uri diff --git a/app/domain/authentication/o_auth/fetch_provider_keys.rb b/app/domain/authentication/o_auth/fetch_provider_keys.rb index 3f021423c5..4c045de8e3 100644 --- a/app/domain/authentication/o_auth/fetch_provider_keys.rb +++ b/app/domain/authentication/o_auth/fetch_provider_keys.rb @@ -8,7 +8,7 @@ module OAuth logger: Rails.logger, discover_identity_provider: DiscoverIdentityProvider.new }, - inputs: %i[provider_uri] + inputs: %i[provider_uri ca_cert] ) do def call discover_provider @@ -23,7 +23,8 @@ def discover_provider def discovered_provider @discovered_provider ||= @discover_identity_provider.( - provider_uri: @provider_uri + provider_uri: @provider_uri, + ca_cert: @ca_cert ) end diff --git a/app/domain/authentication/o_auth/verify_and_decode_token.rb b/app/domain/authentication/o_auth/verify_and_decode_token.rb index da87403def..13d16a16cb 100644 --- a/app/domain/authentication/o_auth/verify_and_decode_token.rb +++ b/app/domain/authentication/o_auth/verify_and_decode_token.rb @@ -23,7 +23,7 @@ module OAuth verify_and_decode_token: ::Authentication::Jwt::VerifyAndDecodeToken.new, logger: Rails.logger }, - inputs: %i[provider_uri token_jwt claims_to_verify] + inputs: %i[provider_uri token_jwt claims_to_verify ca_cert] ) do def call fetch_provider_keys @@ -35,7 +35,8 @@ def call def fetch_provider_keys(force_read: false) provider_keys = @fetch_provider_keys.call( provider_uri: @provider_uri, - refresh: force_read + refresh: force_read, + ca_cert: @ca_cert ) @jwks = provider_keys.jwks diff --git a/app/domain/authentication/util/fetch_authenticator_secrets.rb b/app/domain/authentication/util/fetch_authenticator_secrets.rb index 32f97028cd..a53adfecc4 100644 --- a/app/domain/authentication/util/fetch_authenticator_secrets.rb +++ b/app/domain/authentication/util/fetch_authenticator_secrets.rb @@ -6,25 +6,35 @@ module Util FetchAuthenticatorSecrets = CommandClass.new( dependencies: { - fetch_secrets: ::Conjur::FetchRequiredSecrets.new + fetch_required_secrets: ::Conjur::FetchRequiredSecrets.new, + fetch_optional_secrets: ::Conjur::FetchOptionalSecrets.new, + optional_variable_names: [] }, inputs: %i[conjur_account authenticator_name service_id required_variable_names] ) do def call - @required_variable_names.each_with_object({}) do |variable_name, secrets| - full_variable_name = full_variable_name(variable_name) - secrets[variable_name] = required_secrets[full_variable_name] - end + secret_map_for(@required_variable_names, required_secrets).merge(secret_map_for(@optional_variable_names, optional_secrets)) end private def required_secrets - @required_secrets ||= @fetch_secrets.(resource_ids: required_resource_ids) + @required_secrets ||= @fetch_required_secrets.(resource_ids: resource_ids_for(@required_variable_names)) + end + + def optional_secrets + @optional_secrets ||= @fetch_optional_secrets.(resource_ids: resource_ids_for(@optional_variable_names)) + end + + def secret_map_for(variable_names, secret_values) + variable_names.each_with_object({}) do |variable_name, secrets| + full_variable_name = full_variable_name(variable_name) + secrets[variable_name] = secret_values[full_variable_name] + end end - def required_resource_ids - @required_variable_names.map { |var_name| full_variable_name(var_name) } + def resource_ids_for(variable_names) + variable_names.map { |var_name| full_variable_name(var_name) } end def full_variable_name(var_name) diff --git a/app/domain/conjur/fetch_optional_secrets.rb b/app/domain/conjur/fetch_optional_secrets.rb new file mode 100644 index 0000000000..81d6d196f9 --- /dev/null +++ b/app/domain/conjur/fetch_optional_secrets.rb @@ -0,0 +1,33 @@ +require 'command_class' + +module Conjur + + FetchOptionalSecrets ||= CommandClass.new( + dependencies: { resource_class: ::Resource }, + inputs: [:resource_ids] + ) do + def call + secret_values + end + + private + + def secret_values + transformed_secrets = secrets.transform_values do |secret| + secret ? secret.value : nil + end + transformed_secrets + end + + def resources + @resource_ids.map { |id| [id, @resource_class[id]] }.to_h + end + + def secrets + transformed_secrets = resources.transform_values do |resource| + resource ? resource.secret : nil + end + @secrets ||= transformed_secrets + end + end +end diff --git a/ci/oauth/keycloak/fetch_certificate b/ci/oauth/keycloak/fetch_certificate index 82b7cf5104..e399ff0a1c 100755 --- a/ci/oauth/keycloak/fetch_certificate +++ b/ci/oauth/keycloak/fetch_certificate @@ -14,5 +14,4 @@ openssl s_client \ >/etc/ssl/certs/keycloak.pem hash=$(openssl x509 -hash -in /etc/ssl/certs/keycloak.pem -out /dev/null) - ln -s /etc/ssl/certs/keycloak.pem "/etc/ssl/certs/${hash}.0" || true diff --git a/ci/test_suites/authenticators_oidc/policy.yml b/ci/test_suites/authenticators_oidc/policy.yml index 6a577ce31f..8d7817558d 100644 --- a/ci/test_suites/authenticators_oidc/policy.yml +++ b/ci/test_suites/authenticators_oidc/policy.yml @@ -19,6 +19,9 @@ - !variable id: id-token-user-property + - !variable + id: ca-cert + - !group id: users annotations: diff --git a/dev/policies/authenticators/authn-oidc/keycloak2.yml b/dev/policies/authenticators/authn-oidc/keycloak2.yml index ab96389d08..abe6b27b8c 100644 --- a/dev/policies/authenticators/authn-oidc/keycloak2.yml +++ b/dev/policies/authenticators/authn-oidc/keycloak2.yml @@ -7,6 +7,11 @@ - !policy id: keycloak2 body: + - !webservice + id: status + annotations: + description: Status service to check that the authenticator is configured correctly + - !webservice - !variable provider-uri @@ -16,6 +21,9 @@ # URI of Conjur instance - !variable redirect_uri + # Defines the cert chain to be used for TLS verification + - !variable ca-cert + # Defines the JWT claim to use as the Conjur identifier - !variable claim-mapping diff --git a/dev/start b/dev/start index 0a5f8431be..fa1e58047c 100755 --- a/dev/start +++ b/dev/start @@ -38,6 +38,7 @@ ENABLE_OIDC_OKTA=false ENABLE_ROTATORS=false ENABLE_EPHEMERAL_SECRET=false IDENTITY_USER="" +COMPOSE="docker compose" declare -a required_envvars required_envvars[identity]="IDENTITY_CLIENT_ID IDENTITY_CLIENT_SECRET IDENTITY_PROVIDER_URI" @@ -304,6 +305,9 @@ configure_oidc_v1() { client_load_policy "/src/conjur-server/$policy_path" client_add_secret "conjur/authn-oidc/$service_id/provider-uri" "$provider_uri" client_add_secret "conjur/authn-oidc/$service_id/id-token-user-property" "$token_property" + if [ "$service_id" = "keycloak" ]; then + client_add_secret "conjur/authn-oidc/$service_id/ca-cert" "$($COMPOSE exec conjur cat /etc/ssl/certs/keycloak.pem)" + fi } configure_oidc_v2() { @@ -319,6 +323,9 @@ configure_oidc_v2() { client_add_secret "conjur/authn-oidc/$service_id/client-secret" "$client_secret" client_add_secret "conjur/authn-oidc/$service_id/claim-mapping" "$claim_mapping" client_add_secret "conjur/authn-oidc/$service_id/redirect_uri" "http://localhost:3000/authn-oidc/$service_id/cucumber/authenticate" + if [ "$service_id" = "keycloak2" ]; then + client_add_secret "conjur/authn-oidc/$service_id/ca-cert" "$($COMPOSE exec conjur cat /etc/ssl/certs/keycloak.pem)" + fi client_load_policy "/src/conjur-server/dev/policies/authenticators/authn-oidc/$service_id-users.yml" } diff --git a/spec/app/domain/authentication/authn-oidc/v2/data_objects/authenticator.rb b/spec/app/domain/authentication/authn-oidc/v2/data_objects/authenticator_spec.rb similarity index 84% rename from spec/app/domain/authentication/authn-oidc/v2/data_objects/authenticator.rb rename to spec/app/domain/authentication/authn-oidc/v2/data_objects/authenticator_spec.rb index dba8f716ac..c28190f221 100644 --- a/spec/app/domain/authentication/authn-oidc/v2/data_objects/authenticator.rb +++ b/spec/app/domain/authentication/authn-oidc/v2/data_objects/authenticator_spec.rb @@ -74,12 +74,12 @@ describe '.token_ttl', type: 'unit' do context 'with default initializer' do - it { expect(authenticator.token_ttl).to eq(8.minutes) } + it { expect(authenticator.token_ttl).to eq(1.hour) } end context 'when initialized with a valid duration' do - let (:args) { default_args.merge({ token_ttl: 'PT1H'}) } - it { expect(authenticator.token_ttl).to eq(1.hour)} + let (:args) { default_args.merge({ token_ttl: 'PT2H'}) } + it { expect(authenticator.token_ttl).to eq(2.hour)} end context 'when initialized with an invalid duration' do @@ -89,4 +89,15 @@ }.to raise_error(Errors::Authentication::DataObjects::InvalidTokenTTL) } end end + + describe '.ca_cert', type: 'unit' do + context 'with default initializer' do + it { expect(authenticator.ca_cert).to eq(nil) } + end + + context 'when initialized with a value' do + let (:args) { default_args.merge({ ca_cert: 'cert'}) } + it { expect(authenticator.ca_cert).to eq('cert')} + end + end end diff --git a/spec/app/domain/authentication/authn-oidc/validate_status_spec.rb b/spec/app/domain/authentication/authn-oidc/validate_status_spec.rb index c7010877c6..503ca9d519 100644 --- a/spec/app/domain/authentication/authn-oidc/validate_status_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/validate_status_spec.rb @@ -5,7 +5,7 @@ let(:account) { "my-acct" } let(:service) { "my-service" } - include_context "fetch secrets", %w[provider-uri id-token-user-property] + include_context "fetch secrets", %w[provider-uri id-token-user-property ca-cert] let(:test_oidc_discovery_error) { "test-oidc-discovery-error" } diff --git a/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb b/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb index 0ceb9f3a53..e53d7fece0 100644 --- a/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb +++ b/spec/app/domain/authentication/o_auth/discover_identity_provider_spec.rb @@ -22,7 +22,8 @@ def mock_discovery_provider(error:) Authentication::OAuth::DiscoverIdentityProvider.new( open_id_discovery_service: mock_discovery_provider(error: nil) ).call( - provider_uri: test_provider_uri + provider_uri: test_provider_uri, + ca_cert: nil ) end @@ -41,7 +42,8 @@ def mock_discovery_provider(error:) Authentication::OAuth::DiscoverIdentityProvider.new( open_id_discovery_service: mock_discovery_provider(error: Errno::ETIMEDOUT) ).call( - provider_uri: test_provider_uri + provider_uri: test_provider_uri, + ca_cert: nil ) end @@ -54,7 +56,8 @@ def mock_discovery_provider(error:) Authentication::OAuth::DiscoverIdentityProvider.new( open_id_discovery_service: mock_discovery_provider(error: test_error) ).call( - provider_uri: test_provider_uri + provider_uri: test_provider_uri, + ca_cert: nil ) end diff --git a/spec/app/domain/conjur/fetch_optional_secrets_spec.rb b/spec/app/domain/conjur/fetch_optional_secrets_spec.rb new file mode 100644 index 0000000000..b8c3bc5001 --- /dev/null +++ b/spec/app/domain/conjur/fetch_optional_secrets_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' +require 'conjur/fetch_optional_secrets' +require 'util/stubs/deep_double' + +RSpec.describe('Conjur::FetchOptionalSecrets') do + def fetch_secrets(repo) + Conjur::FetchOptionalSecrets + .new(resource_class: repo) + .(resource_ids: %w[resource1 resource2]) + end + + DeepDouble = Util::Stubs::DeepDouble + + context 'when the secrets exist' do + let(:repo_with_secrets) do + DeepDouble.new('ResourceRepo', + '[]': { + 'resource1' => { secret: { value: 'secret1' } }, + 'resource2' => { secret: { value: 'secret2' } } + }) + end + + it 'returns a hash of the secret values indexed by resource id' do + expect(fetch_secrets(repo_with_secrets)).to eq( + { 'resource1' => 'secret1', 'resource2' => 'secret2' } + ) + end + end + + context 'when resources are missing' do + let(:repo_missing_resource) do + DeepDouble.new('ResourceRepo', + '[]': { + 'resource1' => nil, + 'resource2' => { secret: { value: 'secret2' } } + }) + end + + it 'returns a hash of the requested resources with nil values' do + expect(fetch_secrets(repo_missing_resource)).to eq( + { 'resource1' => nil, 'resource2' => 'secret2' } + ) + end + end + + context 'when secrets are missing' do + let(:repo_missing_secret) do + DeepDouble.new('ResourceRepo', + '[]': { + 'resource1' => { secret: { value: 'secret1' } }, + 'resource2' => { secret: nil } + }) + end + + it 'returns a hash of the secret values with nil values' do + expect(fetch_secrets(repo_missing_secret)).to eq( + { 'resource1' => 'secret1', 'resource2' => nil } + ) + end + end +end From 06836e58176a199f86e36756e3e9ed4e53f7c2d0 Mon Sep 17 00:00:00 2001 From: Glen Johnson Date: Thu, 31 Aug 2023 07:46:38 -0600 Subject: [PATCH 210/665] Review feedback and codeclimate adjustments (cherry picked from commit 31143d5906d627a4a359f115863ce3c9c585de9d) --- .../authentication/util/fetch_authenticator_secrets.rb | 10 +++++----- app/domain/conjur/fetch_optional_secrets.rb | 8 +++----- spec/app/domain/conjur/fetch_optional_secrets_spec.rb | 8 +++----- spec/app/domain/conjur/fetch_required_secrets_spec.rb | 7 +++---- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/app/domain/authentication/util/fetch_authenticator_secrets.rb b/app/domain/authentication/util/fetch_authenticator_secrets.rb index a53adfecc4..3ae11f7368 100644 --- a/app/domain/authentication/util/fetch_authenticator_secrets.rb +++ b/app/domain/authentication/util/fetch_authenticator_secrets.rb @@ -13,7 +13,7 @@ module Util inputs: %i[conjur_account authenticator_name service_id required_variable_names] ) do def call - secret_map_for(@required_variable_names, required_secrets).merge(secret_map_for(@optional_variable_names, optional_secrets)) + secret_map_for(required_secrets).merge(secret_map_for(optional_secrets)) end private @@ -26,10 +26,10 @@ def optional_secrets @optional_secrets ||= @fetch_optional_secrets.(resource_ids: resource_ids_for(@optional_variable_names)) end - def secret_map_for(variable_names, secret_values) - variable_names.each_with_object({}) do |variable_name, secrets| - full_variable_name = full_variable_name(variable_name) - secrets[variable_name] = secret_values[full_variable_name] + def secret_map_for(secret_values) + secret_values.each_with_object({}) do |(full_name, value), secrets| + short_name = full_name.to_s.split('/')[-1] + secrets[short_name] = value end end diff --git a/app/domain/conjur/fetch_optional_secrets.rb b/app/domain/conjur/fetch_optional_secrets.rb index 81d6d196f9..0c3ab588e2 100644 --- a/app/domain/conjur/fetch_optional_secrets.rb +++ b/app/domain/conjur/fetch_optional_secrets.rb @@ -13,21 +13,19 @@ def call private def secret_values - transformed_secrets = secrets.transform_values do |secret| + secrets.transform_values do |secret| secret ? secret.value : nil end - transformed_secrets end def resources - @resource_ids.map { |id| [id, @resource_class[id]] }.to_h + @resources ||= @resource_ids.map { |id| [id, @resource_class[id]] }.to_h end def secrets - transformed_secrets = resources.transform_values do |resource| + @secrets ||= resources.transform_values do |resource| resource ? resource.secret : nil end - @secrets ||= transformed_secrets end end end diff --git a/spec/app/domain/conjur/fetch_optional_secrets_spec.rb b/spec/app/domain/conjur/fetch_optional_secrets_spec.rb index b8c3bc5001..1ad10a68f0 100644 --- a/spec/app/domain/conjur/fetch_optional_secrets_spec.rb +++ b/spec/app/domain/conjur/fetch_optional_secrets_spec.rb @@ -9,11 +9,9 @@ def fetch_secrets(repo) .(resource_ids: %w[resource1 resource2]) end - DeepDouble = Util::Stubs::DeepDouble - context 'when the secrets exist' do let(:repo_with_secrets) do - DeepDouble.new('ResourceRepo', + Util::Stubs::DeepDouble.new('OptionalResourceRepo', '[]': { 'resource1' => { secret: { value: 'secret1' } }, 'resource2' => { secret: { value: 'secret2' } } @@ -29,7 +27,7 @@ def fetch_secrets(repo) context 'when resources are missing' do let(:repo_missing_resource) do - DeepDouble.new('ResourceRepo', + Util::Stubs::DeepDouble.new('ResourceRepo', '[]': { 'resource1' => nil, 'resource2' => { secret: { value: 'secret2' } } @@ -45,7 +43,7 @@ def fetch_secrets(repo) context 'when secrets are missing' do let(:repo_missing_secret) do - DeepDouble.new('ResourceRepo', + Util::Stubs::DeepDouble.new('ResourceRepo', '[]': { 'resource1' => { secret: { value: 'secret1' } }, 'resource2' => { secret: nil } diff --git a/spec/app/domain/conjur/fetch_required_secrets_spec.rb b/spec/app/domain/conjur/fetch_required_secrets_spec.rb index 04b1407c2e..66339bc2b0 100644 --- a/spec/app/domain/conjur/fetch_required_secrets_spec.rb +++ b/spec/app/domain/conjur/fetch_required_secrets_spec.rb @@ -9,11 +9,10 @@ def fetch_secrets(repo) .(resource_ids: %w[resource1 resource2]) end - DeepDouble = Util::Stubs::DeepDouble context 'when the secrets exist' do let(:repo_with_secrets) do - DeepDouble.new('ResourceRepo', + Util::Stubs::DeepDouble.new('ResourceRepo', '[]': { 'resource1' => { secret: { value: 'secret1' } }, 'resource2' => { secret: { value: 'secret2' } } @@ -29,7 +28,7 @@ def fetch_secrets(repo) context 'when resources are missing' do let(:repo_missing_resource) do - DeepDouble.new('ResourceRepo', + Util::Stubs::DeepDouble.new('ResourceRepo', '[]': { 'resource1' => nil, 'resource2' => { secret: { value: 'secret2' } } @@ -45,7 +44,7 @@ def fetch_secrets(repo) context 'when secrets are missing' do let(:repo_missing_secret) do - DeepDouble.new('ResourceRepo', + Util::Stubs::DeepDouble.new('ResourceRepo', '[]': { 'resource1' => { secret: { value: 'secret1' } }, 'resource2' => { secret: nil } From 226d5e2833ee59bf8d266db145b9771340057089 Mon Sep 17 00:00:00 2001 From: Matthew Felgate Date: Tue, 29 Aug 2023 23:18:50 +0300 Subject: [PATCH 211/665] Fix linting issues (cherry picked from commit 46d6fbc33484ddc258fc5189ecdc73eca9ea5e08) --- app/views/status/index.html.erb | 4 ++-- ci/coverage-report-generator/run.sh | 10 +++++----- config/puma.rb | 1 + cucumber/api/features/step_definitions/export_steps.rb | 2 +- cucumber/api/features/support/hooks.rb | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/views/status/index.html.erb b/app/views/status/index.html.erb index a60957aae3..a36a3c2c10 100644 --- a/app/views/status/index.html.erb +++ b/app/views/status/index.html.erb @@ -24,7 +24,7 @@

- +