diff --git a/.gitignore b/.gitignore index d8d3be6c60..ede6e0d417 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,7 @@ gems/*/Gemfile.lock # Container commit marker conjur_git_commit +# AuthnOIDC V2 w/ Identity setup +dev/policies/authenticators/authn-oidc/identity-users.yml + VERSION diff --git a/CHANGELOG.md b/CHANGELOG.md index 1410f7e347..23a48a6f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,25 @@ 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.19.3] - 2023-03-21 +## [1.19.5] - 2023-05-16 + +### Security +- Update bundler to 2.2.33 to remove CVE-2021-43809 + [cyberark/conjur#2804](https://github.com/cyberark/conjur/pull/2804/files) + +### Fixed +- AuthnJWT now supports claims that include hyphens and inline namespaces. + [cyberark/conjur#2792](https://github.com/cyberark/conjur/pull/2792) +- 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 -- Removes support for disabling the `CONJUR_FEATURE_PKCE_SUPPORT_ENABLED` flag. - [cyberark/conjur#2713](https://github.com/cyberark/conjur/pull/2713) -- Routes on the `/roles/` API endpoints now correctly verify the existing of - a Role and return `404` when it doesn't exist or the caller has insufficient - privilege. - [cyberark/conjur#2755](https://github.com/cyberark/conjur/pull/2755) +- OIDC tokens will now have a default ttl of 60 mins + [cyberark/conjur#2800](https://github.com/cyberark/conjur/pull/2800) -## [1.19.2] - 2023-02-01 +## [1.19.3] - 2023-04-17 ### Added - Conjur now logs when it detects that the Conjur configuration file @@ -31,11 +39,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. access controls work as expected for this role to access itself. [cyberark/conjur#2757](https://github.com/cyberark/conjur/pull/2757) +### Changed +- Removes support for disabling the `CONJUR_FEATURE_PKCE_SUPPORT_ENABLED` flag. + [cyberark/conjur#2713](https://github.com/cyberark/conjur/pull/2713) +- Routes on the `/roles/` API endpoints now correctly verify the existing of + a Role and return `404` when it doesn't exist or the caller has insufficient + privilege. + [cyberark/conjur#2755](https://github.com/cyberark/conjur/pull/2755) + ### Fixed - Fixed a thread-safety bug in secret retrieval when multiple threads attempt to decrypt a secret value with Slosilo/OpenSSL. [cyberark/slosilo#31](https://github.com/cyberark/slosilo/pull/31) [cyberark/conjur#2718](https://github.com/cyberark/conjur/pull/2718) +- Incomplete HTTP proxy support in the Kubernetes Authenticator is fixed. This + allows for an HTTP proxy between Conjur and the Kubernetes API. + [cyberark/conjur#2766](https://github.com/cyberark/conjur/pull/2766) ### Security - Updated github-pages version in docs/Gemfile to allow upgrading activesupport @@ -45,6 +64,11 @@ 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 + 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) + [cyberark/conjur#2776](https://github.com/cyberark/conjur/pull/2776) ## [1.19.2] - 2022-01-13 @@ -114,7 +138,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - List resources request (`GET /resources`) now produce audit events. - ([cyberark/conjur#2652](https://github.com/cyberark/conjur/pull/2652) + [cyberark/conjur#2652](https://github.com/cyberark/conjur/pull/2652) ### Changed - AWS Access Key Rotation now preserves only one key @@ -1021,7 +1045,10 @@ 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.0...HEAD +[Unreleased]: https://github.com/cyberark/conjur/compare/v1.19.3...HEAD +[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 [1.19.0]: https://github.com/cyberark/conjur/compare/v1.18.5...v1.19.0 [1.18.5]: https://github.com/cyberark/conjur/compare/v1.18.4...v1.18.5 [1.18.4]: https://github.com/cyberark/conjur/compare/v1.18.3...v1.18.4 diff --git a/Dockerfile.fpm b/Dockerfile.fpm index c0a3c6cb9e..ca25aa6995 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -5,7 +5,7 @@ RUN apt-get update -y && \ apt-get install -y zlib1g-dev \ liblzma-dev -ENV BUNDLER_VERSION 2.2.30 +ENV BUNDLER_VERSION 2.2.33 RUN gem install --no-document bundler:$BUNDLER_VERSION fpm RUN mkdir -p /src/opt/conjur/project diff --git a/Gemfile b/Gemfile index 1f06d52394..892a5833e1 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,7 @@ gem "loofah", ">= 2.2.3" # the branch doesn't immediately break this link gem 'conjur-api', '~> 5.pre' gem 'conjur-policy-parser', path: 'gems/policy-parser' -gem 'conjur-rack' +gem 'conjur-rack', path: 'gems/conjur-rack' gem 'conjur-rack-heartbeat' gem 'rack-rewrite' @@ -77,6 +77,8 @@ gem 'openid_connect' gem "anyway_config" gem 'i18n', '~> 1.8.11' +gem 'json_schemer' + group :development, :test do gem 'aruba' gem 'ci_reporter_rspec' @@ -90,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 e3dac5d981..83557888c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,11 @@ +PATH + remote: gems/conjur-rack + specs: + conjur-rack (5.0.0) + conjur-api (< 6) + rack (~> 2) + slosilo (~> 3.0) + PATH remote: gems/policy-parser specs: @@ -8,67 +16,67 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.1) - actionpack (= 6.1.7.1) - activesupport (= 6.1.7.1) + actioncable (6.1.7.3) + actionpack (= 6.1.7.3) + activesupport (= 6.1.7.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.1) - actionpack (= 6.1.7.1) - activejob (= 6.1.7.1) - activerecord (= 6.1.7.1) - activestorage (= 6.1.7.1) - activesupport (= 6.1.7.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) mail (>= 2.7.1) - actionmailer (6.1.7.1) - actionpack (= 6.1.7.1) - actionview (= 6.1.7.1) - activejob (= 6.1.7.1) - activesupport (= 6.1.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) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.7.1) - actionview (= 6.1.7.1) - activesupport (= 6.1.7.1) + actionpack (6.1.7.3) + actionview (= 6.1.7.3) + activesupport (= 6.1.7.3) 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.1) - actionpack (= 6.1.7.1) - activerecord (= 6.1.7.1) - activestorage (= 6.1.7.1) - activesupport (= 6.1.7.1) + 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) nokogiri (>= 1.8.5) - actionview (6.1.7.1) - activesupport (= 6.1.7.1) + actionview (6.1.7.3) + activesupport (= 6.1.7.3) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.1) - activesupport (= 6.1.7.1) + activejob (6.1.7.3) + activesupport (= 6.1.7.3) globalid (>= 0.3.6) - activemodel (6.1.7.1) - activesupport (= 6.1.7.1) - activerecord (6.1.7.1) - activemodel (= 6.1.7.1) - activesupport (= 6.1.7.1) - activestorage (6.1.7.1) - actionpack (= 6.1.7.1) - activejob (= 6.1.7.1) - activerecord (= 6.1.7.1) - activesupport (= 6.1.7.1) + 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) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.1) + activesupport (6.1.7.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) 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) @@ -107,8 +115,8 @@ GEM rspec (>= 2.14, < 4) coderay (1.1.3) command_class (0.0.2) - concurrent-ruby (1.1.10) - conjur-api (5.3.8.pre.194) + concurrent-ruby (1.2.2) + conjur-api (5.4.0) activesupport (>= 4.2) addressable (~> 2.0) rest-client @@ -126,10 +134,6 @@ GEM conjur-cli (~> 6) docker-api (~> 2.0) gli - conjur-rack (5.0.0) - conjur-api (< 6) - rack (~> 2) - slosilo (~> 3.0) conjur-rack-heartbeat (2.2.0) rack contracts (0.17) @@ -203,6 +207,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) event_emitter (0.2.6) eventmachine (1.2.7) @@ -215,9 +221,10 @@ GEM ffi (>= 1.0.0) rake gli (2.21.0) - globalid (1.0.1) + 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) @@ -226,7 +233,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) @@ -245,6 +252,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) @@ -260,10 +272,10 @@ GEM listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.19.1) + loofah (2.20.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) - mail (2.8.0.1) + mail (2.8.1) mini_mime (>= 0.1.1) net-imap net-pop @@ -274,7 +286,7 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2022.0105) mini_mime (1.1.2) - minitest (5.17.0) + minitest (5.18.0) multi_json (1.15.0) multi_test (0.1.2) net-imap (0.3.4) @@ -289,10 +301,10 @@ GEM net-protocol net-ssh (6.1.0) netrc (0.11.0) - nio4r (2.5.8) - nokogiri (1.14.0-x86_64-darwin) + nio4r (2.5.9) + nokogiri (1.14.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.14.0-x86_64-linux) + nokogiri (1.14.3-x86_64-linux) racc (~> 1.4) openid_connect (1.3.0) activemodel @@ -305,6 +317,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) @@ -317,7 +331,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) racc (1.6.2) @@ -329,22 +343,22 @@ GEM json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-rewrite (1.5.1) - rack-test (2.0.2) + rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.1) - actioncable (= 6.1.7.1) - actionmailbox (= 6.1.7.1) - actionmailer (= 6.1.7.1) - actionpack (= 6.1.7.1) - actiontext (= 6.1.7.1) - actionview (= 6.1.7.1) - activejob (= 6.1.7.1) - activemodel (= 6.1.7.1) - activerecord (= 6.1.7.1) - activestorage (= 6.1.7.1) - activesupport (= 6.1.7.1) + 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) bundler (>= 1.15.0) - railties (= 6.1.7.1) + railties (= 6.1.7.3) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) @@ -353,7 +367,7 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.4) + rails-html-sanitizer (1.5.0) loofah (~> 2.19, >= 2.19.1) rails_12factor (0.0.3) rails_serve_static_assets @@ -361,9 +375,9 @@ GEM rails_layout (1.0.42) rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) - railties (6.1.7.1) - actionpack (= 6.1.7.1) - activesupport (= 6.1.7.1) + railties (6.1.7.3) + actionpack (= 6.1.7.3) + activesupport (= 6.1.7.3) method_source rake (>= 12.2) thor (~> 1.0) @@ -378,6 +392,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) @@ -457,13 +472,14 @@ GEM ffi (~> 1.1) table_print (1.5.7) thor (1.2.1) - timeout (0.3.1) - tzinfo (2.0.5) + timeout (0.3.2) + tzinfo (2.0.6) 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) @@ -484,7 +500,7 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xdg (2.2.3) - zeitwerk (2.6.6) + zeitwerk (2.6.7) PLATFORMS x86_64-darwin-20 @@ -505,7 +521,7 @@ DEPENDENCIES conjur-cli (~> 6.2) conjur-debify conjur-policy-parser! - conjur-rack + conjur-rack! conjur-rack-heartbeat csr cucumber (~> 7.1) @@ -522,6 +538,7 @@ DEPENDENCIES i18n (~> 1.8.11) iso8601 jbuilder (~> 2.7.0) + json_schemer json_spec (~> 1.1) jwt (= 2.2.2) kubeclient @@ -532,6 +549,7 @@ DEPENDENCIES nokogiri (>= 1.8.2) openid_connect parallel + parallel_tests pg pry-byebug pry-rails diff --git a/Jenkinsfile b/Jenkinsfile index 5c8d83e4ba..e441466409 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -41,6 +41,7 @@ These are defined in runConjurTests, and also include the one-offs authenticators_k8s rspec_audit policy_parser + conjur_rack azure_authenticator gcp_authenticator */ @@ -76,6 +77,10 @@ if (params.MODE == "PROMOTE") { return } +// 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' } @@ -276,31 +281,237 @@ 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]) + ) + } + } + + 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, + 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' } + + 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: '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]) + ) } } - runConjurTests(params.RUN_ONLY) + agent { label 'executor-v2-rhel-ee' } + + 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: '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: 'Ubuntu version') + sh(script: 'docker --version', label: 'Docker version') + 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: 'testResultEE', + name: 'standardTestResult2', includes: ''' cucumber/*/*.*, container_logs/*/*, @@ -310,59 +521,42 @@ pipeline { ''' ) } + } - post { - always { - dir('ee-test'){ - unstash 'testResultEE' - } - - 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 + // 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]) ) } } - } - } - } - stage('Run environment tests in parallel') { - parallel { - stage('Standard agent tests') { + agent { label 'executor-v2' } environment { CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" } steps { - runConjurTests(params.RUN_ONLY) + 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/* + ''' + ) } } @@ -390,6 +584,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 @@ -620,6 +815,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' @@ -669,9 +872,11 @@ pipeline { junit(''' spec/reports/*.xml, spec/reports-audit/*.xml, + gems/conjur-rack/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/cucumber/*/features/reports/**/*.xml ''' ) @@ -743,12 +948,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)' ) } @@ -764,11 +981,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' @@ -779,16 +1022,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' @@ -804,16 +1047,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' @@ -823,15 +1066,33 @@ def runConjurTests(run_only_str) { "Policy Parser - ${env.STAGE_NAME}": { sh 'cd gems/policy-parser && ./test.sh' } + ], + "conjur_rack": [ + "Rack - ${env.STAGE_NAME}": { + sh 'cd gems/conjur-rack && ./test.sh' + } ] ] +} + +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. @@ -839,6 +1100,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() @@ -846,6 +1108,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.TAG_NAME?.trim()) { // If this is a master or tag build, we want to run all of the tests. So diff --git a/NOTICES.txt b/NOTICES.txt index f4e9647e67..9f776f0a92 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -13,18 +13,18 @@ Section 1: Apache-2.0 Section 2: BSD-2-Clause ->>> https://rubygems.org/gems/base32-crockford/versions/0.1.0 >>> https://rubygems.org/gems/pg/versions/1.2.3 >>> https://rubygems.org/gems/websocket/versions/1.2.9 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 Section 4: MIT ->>> https://rubygems.org/gems/activesupport/versions/6.1.7.1 +>>> https://rubygems.org/gems/activesupport/versions/6.1.7.3 >>> https://rubygems.org/gems/anyway_config/versions/2.2.3 >>> https://rubygems.org/gems/base58/versions/0.2.3 >>> https://rubygems.org/gems/bcrypt/versions/3.1.16 @@ -40,19 +40,19 @@ Section 4: MIT >>> https://rubygems.org/gems/jwt/versions/2.2.2 >>> 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.19.1 +>>> 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.13.10 +>>> https://rubygems.org/gems/nokogiri/versions/1.14.3 >>> https://rubygems.org/gems/openid_connect/versions/1.3.0 >>> https://rubygems.org/gems/rack-rewrite/versions/1.5.1 ->>> https://rubygems.org/gems/rails/versions/6.1.7.1 +>>> https://rubygems.org/gems/rails/versions/6.1.7.3 >>> https://rubygems.org/gems/rake/versions/13.0.6 >>> https://rubygems.org/gems/sequel/versions/5.51.0 >>> https://rubygems.org/gems/sequel-pg_advisory_locking/versions/1.0.1 >>> https://rubygems.org/gems/sequel-postgres-schemata/versions/0.1.3 >>> https://rubygems.org/gems/sequel-rails/versions/1.1.1 >>> https://rubygems.org/gems/simplecov/versions/0.21.2 ->>> https://rubygems.org/gems/slosilo/versions/3.0.0 +>>> https://rubygems.org/gems/slosilo/versions/3.0.1 >>> https://rubygems.org/gems/event_emitter/versions/0.2.6 APPENDIX: Standard License Files and Templates @@ -247,7 +247,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. MIT License is applicable to the following component(s). ->>> https://rubygems.org/gems/activesupport/versions/6.1.7.1 +>>> https://rubygems.org/gems/activesupport/versions/6.1.7.3 Copyright (c) 2005-2018 David Heinemeier Hansson @@ -614,7 +614,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/loofah/versions/2.19.1 +>>> https://rubygems.org/gems/loofah/versions/2.20.0 Copyright (c) 2009 -- 2018 by Mike Dalessio, Bryan Helmkamp @@ -636,28 +636,6 @@ 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/mini_racer/versions/0.2.9 - -Copyright (c) 2016-2019, the mini_racer project authors. - -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. - >>> https://rubygems.org/gems/net-ldap/versions/0.17.0 Copyright 2006–2011 by Francis Cianfrocca and other contributors. @@ -680,7 +658,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/nokogiri/versions/1.13.10 +>>> https://rubygems.org/gems/nokogiri/versions/1.14.3 Copyright 2008 -- 2018 by Aaron Patterson, Mike Dalessio, Charles Nutter, Sergio Arbeo, Patrick Mahoney, Yoko Harada, Akinori MUSHA, John Shahid, Lars Kanis @@ -748,7 +726,7 @@ 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. ->>> https://rubygems.org/gems/rails/versions/6.1.7.1 +>>> https://rubygems.org/gems/rails/versions/6.1.7.3 Copyright (c) 2005-2018 David Heinemeier Hansson @@ -792,27 +770,27 @@ 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/ruby_dep/versions/1.3.1 +>>> >>> https://rubygems.org/gems/sequel/versions/5.51.0 -Copyright (c) 2016 Cezary Baginski +Copyright (c) 2007-2008 Sharon Rosner +Copyright (c) 2008-2023 Jeremy Evans 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 +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 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. +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS 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. >>> https://rubygems.org/gems/sequel-pg_advisory_locking/versions/1.0.1 @@ -880,28 +858,6 @@ 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/sequel/versions/5.50.0 - -Copyright (c) 2007-2008 Sharon Rosner -Copyright (c) 2008-2021 Jeremy Evans - -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 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. - >>> https://rubygems.org/gems/simplecov/versions/0.21.2 Copyright (c) 2010-2015 Christoph Olszowka @@ -924,7 +880,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/slosilo/versions/3.0.0 +>>> https://rubygems.org/gems/slosilo/versions/3.0.1 Copyright (c) 2020 CyberArk Software Ltd. All rights reserved. diff --git a/app/controllers/policy_factories_controller.rb b/app/controllers/policy_factories_controller.rb new file mode 100644 index 0000000000..e67fb352f2 --- /dev/null +++ b/app/controllers/policy_factories_controller.rb @@ -0,0 +1,68 @@ +# 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 + render( + json: response.to_h, + 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..0f0001a4a7 --- /dev/null +++ b/app/db/repository/policy_factory_repository.rb @@ -0,0 +1,123 @@ +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| + # find the most recent version + versions.max { |a, b| a.id <=> 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| a.id <=> 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 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/authentication/authn_iam/Readme.md b/app/domain/authentication/authn_iam/Readme.md new file mode 100644 index 0000000000..fd98fd41a4 --- /dev/null +++ b/app/domain/authentication/authn_iam/Readme.md @@ -0,0 +1,165 @@ +# AWS Authenticator + +This authenticator enables workloads running in AWS to authenticate with Conjur. + +## Policy + +The AWS Authenticator Policy follows the typical pattern for Authenticators. Please note, there are no required variables. + +### Authenticator Policy + +```yaml +# Applied to the root policy +- !policy + id: conjur + body: + - !policy + id: authn-iam + body: + - !policy + id: aws + body: + - !webservice + + - !group authenticatable + + - !permit + role: !group authenticatable + privilege: [ read, authenticate ] + resource: !webservice +``` + +### Host Policy + +```yaml +# Applied to the root policy +- !policy + id: aws + body: + - !policy + id: production + body: + - &hosts + - !host 188945769008/my_app_1 + - !host 188945769008/my-app-2 + - !host 188945769008/MyApp3 + + - !grant + members: *hosts + role: !group conjur/authn-iam/aws/authenticatable +``` + +### How IAM ARNs map to Conjur Roles + +The Conjur AWS Authenticator receives an AWS ARN as a result of the authorization request. Below is a sample: + +``` +arn:aws:sts::188945769008:assumed-role/conjur-role/i-08241b0e31fe23d20 +``` + +The interesting bits (from Conjur's perspective) are as follows: + +- `188945769008` - AWS account ID +- `conjur-role` - IAM role given to this workload + +All other elements or the AWS ARN are ignored by Conjur. + +- The Conjur Role ID must end with `188945769008/conjur-role` +- The Role can be located in a Conjur policy (ie. can have the prefix: `foo/bar/188945769008/conjur-role`) +- A Role can be either a User or a Host +- A Host can be in the Conjur `root` policy +- A User cannot be in the Conjur `root` policy + +## Authentication Workflow + +The authenticator works by passing the headers generated by an AWS signing request to the AWS STS's (Security Token Service) `GetCallerIdentity` endpoint. Conjur uses this signed service to exectute a call to AWS STS (on behalf of the workload we're authenticating). + +AWS STS has two options, global (`sts.amazonaws.com`), and regional (`sts..amazonaws.com`). Some important notes about using the STS endpoint. + +- If using the global endpoint (`sts.amazonaws.com`), the request must be signed using the `us-east-1` region. Signing a request using a different region will cause authentication to fail. +- If using the regional endpoint (preferred by AWS), the request must be signed by by that region. For example, if a request to `sts.eu-west-1.amazonaws.com`, it must be signed with the `eu-west-1` region. + +### Examples + +*All examples are in Ruby.* + +*All examples assume the AWS IAM role of the requestor is `my-app-2`* + +#### Valid Global Request + +```ruby +require 'aws-sigv4' +require 'aws-sdk' + +signed_aws_headers = Aws::Sigv4::Signer.new( + service: 'sts', + region: 'us-east-1', + credentials_provider: Aws::InstanceProfileCredentials.new +).sign_request( + http_method: 'GET', + url: 'https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15' +).headers +``` + +#### Valid Regional Request + +Any region can be used as long as the region is the same the target regional STS endpoint. + +```ruby +require 'aws-sigv4' +require 'aws-sdk' + +signed_aws_headers = Aws::Sigv4::Signer.new( + service: 'sts', + region: 'eu-west-1', + credentials_provider: Aws::InstanceProfileCredentials.new +).sign_request( + http_method: 'GET', + url: 'https://sts.eu-west-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15' +).headers +``` + +#### Retrieve a Conjur Authentication Token + +*Please note, the Ruby SDK does not support many of the authenticators via the SDK. The following is one example of how it might be handled.* + +```ruby +# Retrieve a Conjur authentication token +conjur_host = 'host/aws/production/188945769008/my-app-2' +conjur_url = 'conjur.mycompany.com' +conjur_account = 'demo' + +conjur_uri = URI("https://#{conjur_url}/authn-iam/aws/#{conjur_account}/#{ERB::Util.url_encode(conjur_host)}/authenticate") + +response = Net::HTTP.post_form(conjur_uri, signed_aws_headers) + +Conjur::API.new_from_token(response.body) +``` + +## Troubleshooting + + 1. *Error: CONJ00018E Invalid or expired AWS headers: Credential should be scoped to a valid region.* + + Request was signed by another region. If using the global endpoint, make sure the request was signed using `us-east-1`. If using the regional endpoint, make sure the regional signing request was signed with the corresponding region. + + 2. *Error: CONJ00018E Invalid or expired AWS headers: Signature expired: 20230518T152442Z is now earlier than 20230518T153438Z (20230518T154938Z - 15 min.)* + + The provided headers are no longer validate. Please regenerate the signing request and re-authenticate. + + 3. *Error: CONJ00018E Invalid or expired AWS headers: 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.* + + The provided headers do not exactly match: + + ``` + GET https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + ``` + + or + + ``` + GET https://sts..amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + ``` + + - Verify the signing request uses 'GET' and not another HTTP verb. + - Verify the STS request includes the above parameters. + - If crafting your own signed request (ex. when using Powershell), verify your signed request headers against the above endpoints using a tool like Postman. diff --git a/app/domain/authentication/authn_iam/authenticator.rb b/app/domain/authentication/authn_iam/authenticator.rb index 50ffb454f6..5fab2f3c89 100755 --- a/app/domain/authentication/authn_iam/authenticator.rb +++ b/app/domain/authentication/authn_iam/authenticator.rb @@ -6,51 +6,38 @@ module Authentication module AuthnIam class Authenticator - def initialize(env:) + def initialize(env:, logger: Rails.logger, client: Net::HTTP) @env = env + @logger = logger + @client = client end def valid?(input) - signed_aws_headers = JSON.parse(input.credentials) # input.credentials is JSON holding the AWS signed headers + # input.credentials is JSON holding the AWS signed headers + signed_aws_headers = JSON.parse(input.credentials).transform_keys(&:downcase) + aws_response = response_from_signed_request(signed_aws_headers) - response_hash = identity_hash(response_from_signed_request(signed_aws_headers)) - trusted = response_hash != false - - trusted && iam_role_matches?(input.username, response_hash) - end - - def identity_hash(response) - Rails.logger.debug( - LogMessages::Authentication::AuthnIam::GetCallerIdentityBody.new( - response.body - ) + iam_role_matches?( + login: input.username, + aws_arn: aws_response[:arn], + aws_account: aws_response[:account] ) - - if response.code < 300 - Hash.from_xml(response.body) - else - Rails.logger.error( - Errors::Authentication::AuthnIam::IdentityVerificationErrorCode.new( - response.code - ) - ) - false - end end - def iam_role_matches?(login, response_hash) - split_assumed_role = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Arn"].split(":") + private + # Parses STS Caller Identity response for the Conjur host and resolves it against the provided login + def iam_role_matches?(login:, aws_arn:, aws_account:) # removes the last 2 parts of login to be substituted by the info from getCallerIdentity - host_prefix = (login.split("/")[0..-3]).join("/") - aws_role_name = split_assumed_role[5].split("/")[1] - aws_account_id = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Account"] - aws_user_id = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["UserId"] - host_to_match = "#{host_prefix}/#{aws_account_id}/#{aws_role_name}" + host_prefix = (login.split('/')[0..-3]).join('/') - Rails.logger.debug( + arn_parts = aws_arn.split(':') + aws_role_name = arn_parts[5].split('/')[1] + host_to_match = "#{host_prefix}/#{aws_account}/#{aws_role_name}" + + @logger.debug( LogMessages::Authentication::AuthnIam::AttemptToMatchHost.new( - aws_user_id, + login, host_to_match ) ) @@ -58,20 +45,37 @@ def iam_role_matches?(login, response_hash) login.eql?(host_to_match) end - def aws_signed_url - return 'https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15' + def extract_relevant_data(response) + { + arn: response.dig('GetCallerIdentityResponse', 'GetCallerIdentityResult', 'Arn'), + account: response.dig('GetCallerIdentityResponse', 'GetCallerIdentityResult', 'Account') + } end - def response_from_signed_request(aws_headers) - Rails.logger.debug(LogMessages::Authentication::AuthnIam::RetrieveIamIdentity.new) + # 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") begin - RestClient.get(aws_signed_url, headers = aws_headers) - rescue RestClient::ExceptionWithResponse => e - Rails.logger.error(Errors::Authentication::AuthnIam::VerificationError.new(e.to_s)) - raise Errors::Authentication::AuthnIam::InvalidAWSHeaders, e.to_s + @client.get_response(aws_request, signed_headers) + + # Handle any network failures with a generic verification error + rescue StandardError => e + raise(Errors::Authentication::AuthnIam::VerificationError.new(e)) end end + + # Verify request with STS and handle response (happy or sad paths) + def response_from_signed_request(aws_headers) + response = attempt_signed_request(aws_headers) + body = Hash.from_xml(response.body) + + return extract_relevant_data(body) if response.code.to_i == 200 + + raise( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + body.dig('ErrorResponse', 'Error', 'Message').to_s.strip + ) + end end end end - diff --git a/app/domain/authentication/authn_jwt/consts.rb b/app/domain/authentication/authn_jwt/consts.rb index b56cc9ae14..a8ea90e189 100644 --- a/app/domain/authentication/authn_jwt/consts.rb +++ b/app/domain/authentication/authn_jwt/consts.rb @@ -38,7 +38,7 @@ module AuthnJwt CLAIMS_CHARACTER_DELIMITER = "," TUPLE_CHARACTER_DELIMITER = ":" - PURE_CLAIM_NAME_REGEX = /[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*/.freeze + PURE_CLAIM_NAME_REGEX = /[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*/.freeze PURE_NESTED_CLAIM_NAME_REGEX = /^#{PURE_CLAIM_NAME_REGEX.source}(#{PATH_DELIMITER}#{PURE_CLAIM_NAME_REGEX.source})*$/.freeze SIGNING_KEY_RESOURCES_NAMES = [ diff --git a/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb b/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb index 00d88bed32..90ace1b83b 100644 --- a/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb +++ b/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb @@ -85,7 +85,16 @@ def id_claim_key def id_claim_value return @id_claim_value if @id_claim_value - @id_claim_value = @jwt_authenticator_input.decoded_token.dig( + token = @jwt_authenticator_input.decoded_token + # Parsing the claim path means claims with slashes are interpreted + # as nested claims - for example 'a/b/c' corresponds to the doubly- + # nested claim: {"a":{"b":{"c":"value"}}}. + # + # We should also support claims that contain slashes as namespace + # indicators, such as 'namespace.com/claim', which would correspond + # to the top-level claim: {"namespace.com/claim":"value"}. + @id_claim_value = token[@id_claim_key] + @id_claim_value ||= token.dig( *parsed_claim_path ) end diff --git a/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb b/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb index 2c50291241..73474fa814 100644 --- a/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb +++ b/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb @@ -25,7 +25,15 @@ def valid_restriction?(restriction) raise Errors::Authentication::ResourceRestrictions::EmptyAnnotationGiven, annotation_name end - claim_value = @decoded_token.dig(*parsed_claim_path(claim_name)) + # Parsing the claim path means claims with slashes are interpreted + # as nested claims - for example 'a/b/c' corresponds to the doubly- + # nested claim: {"a":{"b":{"c":"value"}}}. + # + # We should also support claims that contain slashes as namespace + # indicators, such as 'namespace.com/claim', which would correspond + # to the top-level claim: {"namespace.com/claim":"value"}. + claim_value = @decoded_token[claim_name] + claim_value ||= @decoded_token.dig(*parsed_claim_path(claim_name)) if claim_value.nil? raise Errors::Authentication::AuthnJwt::JwtTokenClaimIsMissing, claim_name_for_error(annotation_name, claim_name) diff --git a/app/domain/authentication/authn_k8s/execute_command_in_container.rb b/app/domain/authentication/authn_k8s/execute_command_in_container.rb index 958f13d2f4..3452a2afaf 100644 --- a/app/domain/authentication/authn_k8s/execute_command_in_container.rb +++ b/app/domain/authentication/authn_k8s/execute_command_in_container.rb @@ -45,10 +45,8 @@ def init_ws_client def ws_client @ws_client ||= @websocket_client.connect( ws_exec_url, - { - headers: headers, - cert_store: @k8s_object_lookup.cert_store - } + headers: headers, + cert_store: @k8s_object_lookup.cert_store ) end diff --git a/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb b/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb new file mode 100644 index 0000000000..00036add99 --- /dev/null +++ b/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'delegate' +require 'socket' + +module Authentication + module AuthnK8s + # A proxy socket first establishes a TCP connection through a configured + # proxy server + # + # :reek:TooManyInstanceVariables + # :reek:InstanceVariableAssumption for @proxy_socket + class ProxiedTcpSocket < SimpleDelegator + attr_reader :tcp_socket + + def initialize( + proxy_uri:, + destination_host:, + destination_port:, + timeout: 60, # seconds + + # Injected dependencies + logger: Rails.logger + ) + @proxy_uri = proxy_uri + @destination_host = destination_host + @destination_port = destination_port + @timeout = timeout + @logger = logger + + @tcp_socket = connect_proxy_socket + + # Connect to the proxy + super(@tcp_socket) + end + + protected + + def connect_proxy_socket + # We log the proxy host and port specifically because the full URI may + # contain authorization fields. + @logger.debug( + "Connecting to '#{@destination_host}:#{@destination_port}' " \ + "through proxy server: '#{@proxy_uri.host}:#{@proxy_uri.port}'" + ) + + @proxy_socket = TCPSocket.new( + @proxy_uri.host, + @proxy_uri.port, + connect_timeout: @timeout + ) + + # Send proxy connection handshake + @proxy_socket.write(proxy_connect_string) + + # This will block until the response is received. It raises an + # exception if the proxy response is not received or is invalid. + wait_for_proxy_response + + @proxy_socket + end + + # :reek:DuplicateMethodCall for @proxy_uri methods + def wait_for_proxy_response + # Set the deadline time for the socket response. + deadline = Time.now + @timeout + + # Loop until the expected string is received or the deadline is reached. + response = '' + while !response.include?("\r\n\r\n") && Time.now < deadline + # Calculate the remaining time until the deadline. + remaining_time = deadline - Time.now + + # Wait until the socket is ready to be read, or until the + # deadline is reached. + ready = IO.select([@proxy_socket], nil, nil, remaining_time) + + unless ready + # The deadline has been reached without receiving the + # expected string + raise "Timed out waiting for the proxy " \ + "('#{@proxy_uri.host}:#{@proxy_uri.port}') to respond. " \ + "Received: '#{response.strip}'" + end + + # Read from the socket and append to the response string + response += @proxy_socket.read(1) + end + + # Verify we received a valid connection response + return if response.downcase.include?('200 connection established') + + # If we didn't receive the expected response, raise an error + raise "Proxy ('#{@proxy_uri.host}:#{@proxy_uri.port}') returned an " \ + "invalid response: '#{response.strip}'" + end + + # For spec details, see: + # https://httpwg.org/specs/rfc9110.html#CONNECT + def proxy_connect_string + connect_string = \ + "CONNECT #{@destination_host}:#{@destination_port} HTTP/1.1\r\n" \ + "Host: #{@destination_host}\r\n" + + if proxy_authorization + connect_string += \ + "Proxy-Authorization: Basic #{proxy_authorization}\r\n" + end + + connect_string + "\r\n" + end + + # :reek:DuplicateMethodCall because of accessing #user and #password twice + def proxy_authorization + return unless @proxy_uri.user && @proxy_uri.password + + Base64.strict_encode64("#{@proxy_uri.user}:#{@proxy_uri.password}") + end + end + end +end diff --git a/app/domain/authentication/authn_k8s/secure_tcp_socket.rb b/app/domain/authentication/authn_k8s/secure_tcp_socket.rb new file mode 100644 index 0000000000..287376358a --- /dev/null +++ b/app/domain/authentication/authn_k8s/secure_tcp_socket.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'delegate' +require 'openssl' + +module Authentication + module AuthnK8s + # A secure socket wraps an existing TCP socket and establishes a secure + # TLS context with it. + # + # :reek:TooManyInstanceVariables + class SecureTcpSocket < SimpleDelegator + def self.default_cert_store + OpenSSL::X509::Store.new.tap(&:set_default_paths) + end + + def initialize( + socket:, + + # Optional keyword arguments to configure the TLS behavior + hostname: nil, + headers: nil, + cert_store: SecureTcpSocket.default_cert_store, + verify_mode: OpenSSL::SSL::VERIFY_PEER + ) + @socket = socket + + @hostname = hostname + @headers = headers + @cert_store = cert_store + @verify_mode = verify_mode + + super(secure_socket) + end + + protected + + def secure_socket + # Wrap the provided TCP socket with an SSLSocket + OpenSSL::SSL::SSLSocket.new(@socket, openssl_context).tap do |socket| + # support SNI, see https://www.cloudflare.com/en-gb/learning/ssl/what-is-sni/ + # don't set SNI hostname for IP address per RFC 6066, section 3. + socket.hostname = @hostname unless ip_address? + + # Establish secure connection + socket.connect + socket.post_connection_check(@hostname) + end + end + + def ip_address? + @hostname.match?(Resolv::IPv4::Regex) || + @hostname.match?(Resolv::IPv6::Regex) + end + + def openssl_context + OpenSSL::SSL::SSLContext.new.tap do |ctx| + # Set the certificate store + ctx.cert_store = @cert_store + + # Verify the TLS peer by default unless a verify mode is specified + ctx.verify_mode = @verify_mode + + # Avoid openssl warning on hostname verification for IP address + ctx.verify_hostname = false if ip_address? + end + end + end + end +end diff --git a/app/domain/authentication/authn_k8s/web_socket_client.rb b/app/domain/authentication/authn_k8s/web_socket_client.rb index 8ccd1e413b..078f4df877 100644 --- a/app/domain/authentication/authn_k8s/web_socket_client.rb +++ b/app/domain/authentication/authn_k8s/web_socket_client.rb @@ -1,115 +1,82 @@ +# frozen_string_literal: true + ## This code is based on github.com/shokai/websocket-client-simple (MIT License) require 'event_emitter' require 'websocket' require 'resolv' require 'openssl' +require 'uri' # Utility class for processing WebSocket messages. +# +# :reek:InstanceVariableAssumption for @frame and @handshake_finished. +# :reek:RepeatedConditional for secure? checks. module Authentication module AuthnK8s class WebSocketClient include EventEmitter - attr_reader :url, :handshake - def self.connect(url, options = {}) - client = WebSocketClient.new + def self.connect(url, **options) + client = WebSocketClient.new(url, **options) yield(client) if block_given? - client.connect(url, options) + client.connect client end - # connect provides options :hostname, :headers, :ssl_version, :cert_store, :verify_mode - def connect(url, options = {}) - return if @socket + # Used by WebSocketClientEventHandler + attr_reader :handshake - @url = url - uri = URI.parse(url) - is_secure_connection = %w[https wss].include?(uri.scheme) - @socket = TCPSocket.new(uri.host, - uri.port || (is_secure_connection ? 443 : 80)) - if is_secure_connection - ctx = OpenSSL::SSL::SSLContext.new - ssl_version = options[:ssl_version] - ctx.ssl_version = ssl_version if ssl_version - ctx.verify_mode = options[:verify_mode] || OpenSSL::SSL::VERIFY_PEER # use VERIFY_PEER for verification - cert_store = options[:cert_store] - - unless cert_store - cert_store = OpenSSL::X509::Store.new - cert_store.set_default_paths - end - ctx.cert_store = cert_store - - use_sni = false - ssl_host_address = options[:hostname] || uri.host # use the param :hostname or default to the host of the url argument - - case uri.host - when Resolv::IPv4::Regex, Resolv::IPv6::Regex - # don't set SNI, as IP addresses in SNI is not valid - # per RFC 6066, section 3. - - # Avoid openssl warning - ctx.verify_hostname = false - else - use_sni = true - end + def initialize( + url, - @socket = ::OpenSSL::SSL::SSLSocket.new(@socket, ctx) + # Optional keyword arguments to configure the secure socket behavior + hostname: nil, + headers: nil, + cert_store: SecureTcpSocket.default_cert_store, + verify_mode: OpenSSL::SSL::VERIFY_PEER + ) + # Parse the given url to ensure it's valid + @uri = URI.parse(url) - # support SNI, see https://www.cloudflare.com/en-gb/learning/ssl/what-is-sni/ - @socket.hostname = ssl_host_address if use_sni + # Use the provided port or default to the standard ports + @port = @uri.port || (secure? ? 443 : 80) - @socket.connect + @hostname = hostname + @headers = headers + @cert_store = cert_store + @verify_mode = verify_mode + end - # mandatory hostname verification applicable to both hostnames and IP addresses - @socket.post_connection_check(ssl_host_address) - end - @handshake = ::WebSocket::Handshake::Client.new(url: url, headers: options[:headers]) - @handshaked = false - @pipe_broken = false - frame = ::WebSocket::Frame::Incoming::Client.new - @closed = false - once(:__close) do |err| - close - emit(:close, err) - end + def connect + # Do nothing if already connected + return if @socket - @thread = Thread.new do - until @closed - begin - unless recv_data = @socket.getc - sleep(1) - next - end - if @handshaked - frame << recv_data - while msg = frame.next - emit(:message, msg) - end - else - @handshake << recv_data + # Establish initial connection to server + open_socket - # completed handshake - if @handshake.finished? - @handshaked = true - emit(:open) - end - end - rescue => e - emit(:error, e) - end - end - end + # If the connection uses TLS, establish the secure context + secure_socket if secure? + # Begin websocket IO loop in a separate thread + begin_event_loop + + # Send initial websocket handshake + @handshake ||= WebSocket::Handshake::Client.new( + url: @uri.to_s, + headers: @headers + ) @socket.write(@handshake.to_s) end def send(data, opt = { type: :text }) - return if !@handshaked || @closed + return if !@handshake_finished || @closed type = opt[:type] - frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version) + frame = ::WebSocket::Frame::Outgoing::Client.new( + data: data, + type: type, version: @handshake.version + ) begin @socket.write(frame.to_s) rescue Errno::EPIPE => e @@ -124,10 +91,13 @@ def close unless @pipe_broken send(nil, type: :close) end + @closed = true @socket&.close @socket = nil + emit(:__close) + Thread.kill(@thread) if @thread end @@ -135,6 +105,104 @@ def open? @handshake.finished? && !@closed end + protected + + # :reek:DuplicateMethodCall for @uri.host + def open_socket + @socket = if proxy_uri + ProxiedTcpSocket.new( + proxy_uri: proxy_uri, + destination_host: @uri.host, + destination_port: @port + ).tcp_socket + else + TCPSocket.new(@uri.host, @port) + end + end + + # Wrap the given tcp_socket in an SSL socket to secure the connection + def secure_socket + @socket = SecureTcpSocket.new( + socket: @socket, + hostname: @hostname || @uri.host, + cert_store: @cert_store, + verify_mode: @verify_mode + ) + end + + # This returns the proxy url relevant to the connection from the + # environment. If the server connection uses TLS, then use the + # https_proxy value, otherwise use the http_proxy value. + def proxy_uri + @proxy_uri ||= begin + proxy_url = if secure? + ENV['https_proxy'] || ENV['HTTPS_PROXY'] + else + ENV['http_proxy'] || ENV['HTTP_PROXY'] + end + + URI.parse(proxy_url) + rescue URI::InvalidURIError + nil + end + end + + def secure? + %w[https wss].include?(@uri.scheme) + end + + # :reek:TooManyStatements + def begin_event_loop + @handshake_finished = false + @pipe_broken = false + @closed = false + + # Set up event handler with the websocket is closed + once(:__close) do |err| + close + emit(:close, err) + end + + @thread = Thread.new do + until @closed + begin + unless recv_data = @socket.getc + sleep(1) + next + end + + if @handshake_finished + process_incoming_data(recv_data) + else + process_handshake_data(recv_data) + end + rescue => e + emit(:error, e) + end + end + end + end + + def process_incoming_data(recv_data) + # Initialize the frame if this is the first data received + @frame ||= WebSocket::Frame::Incoming::Client.new + + # Add data to the frame and handle websocket events + @frame << recv_data + while msg = @frame.next + emit(:message, msg) + end + end + + def process_handshake_data(recv_data) + @handshake << recv_data + + # Continue waiting if the websocket handshake isn't finished + return unless @handshake.finished? + + @handshake_finished = true + emit(:open) + end end end end diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb index 60756403de..80a82c00c2 100644 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ b/app/domain/authentication/authn_oidc/v2/client.rb @@ -45,10 +45,12 @@ def callback(code:, nonce:, code_verifier: nil) rescue Rack::OAuth2::Client::Error => e # Only handle the expected errors related to access token retrieval. case e.message - when /PKCE verification failed/ + when /PKCE verification failed/, # Okta's PKCE failure msg + /challenge mismatch/ # Identity's PKCE failure msg raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, 'PKCE verification failed' - when /The authorization code is invalid or has expired/ + when /The authorization code is invalid or has expired/, # Okta's reused code msg + /supplied code does not match known request/ # Identity's reused code msg raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, 'Authorization code is invalid or has expired' when /Code not valid/ 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 a5c3d661e2..15f4bdffe5 100644 --- a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb +++ b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb @@ -29,7 +29,7 @@ def initialize( name: nil, response_type: 'code', provider_scope: nil, - token_ttl: 'PT8M' + token_ttl: 'PT60M' ) @account = account @provider_uri = provider_uri diff --git a/app/domain/errors.rb b/app/domain/errors.rb index eb5ef49bd6..5018fe910f 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -270,20 +270,14 @@ module AuthnOidc module AuthnIam InvalidAWSHeaders = ::Util::TrackableErrorClass.new( - msg: "Invalid or expired AWS headers: {0}", + msg: "Invalid or expired AWS headers: {0-message}", code: "CONJ00018E" ) - VerificationError = ::Util::TrackableLogMessageClass.new( + VerificationError = ::Util::TrackableErrorClass.new( msg: "Verification of IAM identity failed with exception: {0-exception}", code: "CONJ00063E" ) - - IdentityVerificationErrorCode = ::Util::TrackableLogMessageClass.new( - msg: "Verification of IAM identity failed with HTTP code: {0-http-code}", - code: "CONJ00064E" - ) - end module AuthnK8s diff --git a/app/domain/factories/Readme.md b/app/domain/factories/Readme.md new file mode 100644 index 0000000000..8ba7003c08 --- /dev/null +++ b/app/domain/factories/Readme.md @@ -0,0 +1,540 @@ +# Policy Factory + +## Setup + +Setup will follow the following workflow: + +```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: + +```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. + +```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 +```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 + + +```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) + +```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 + +```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 + +```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..371e7ead97 --- /dev/null +++ b/app/domain/factories/create_from_policy_factory.rb @@ -0,0 +1,197 @@ +# 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 + + # 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/renderer.rb b/app/domain/factories/renderer.rb new file mode 100644 index 0000000000..6e77d6c8df --- /dev/null +++ b/app/domain/factories/renderer.rb @@ -0,0 +1,23 @@ +require 'responses' + +module Factories + class Renderer + def initialize(render_engine: ERB) + @render_engine = render_engine + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def render(template:, variables:) + @success.new(@render_engine.new(template, nil, '-').result_with_hash(variables)) + + # If variable in template is missing from variable list + rescue NameError => 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/factories/templates/authenticators/v1/authn_oidc.rb b/app/domain/factories/templates/authenticators/v1/authn_oidc.rb new file mode 100644 index 0000000000..fbda6adf8e --- /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 %> + <% if defined?(annotations) %> + annotations: + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + <% 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 authenticator 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..5273cead4f --- /dev/null +++ b/app/domain/factories/templates/base/v1/base_policy.rb @@ -0,0 +1,55 @@ +# 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 + id: v1/user + annotations: + description: Create a new User + + - !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..68fb2442a8 --- /dev/null +++ b/app/domain/factories/templates/connections/v1/database.rb @@ -0,0 +1,97 @@ +# 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/database + 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 ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this database connection into", + "type": "string" + }, + "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..8deb053e86 --- /dev/null +++ b/app/domain/factories/templates/core/v1/grant.rb @@ -0,0 +1,48 @@ +# 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 Role", + "type": "object", + "properties": { + "branch": { + "description": "Policy branch to load this group into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations to add to the group", + "type": "object" + } + }, + "required": %w[branch] + } + }.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..4d88549332 --- /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 ID", + "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 to add to the group", + "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..6c80123660 --- /dev/null +++ b/app/domain/factories/templates/core/v1/managed_policy.rb @@ -0,0 +1,59 @@ +# 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 + <% if defined?(annotations) %> + annotations: + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + <% 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 to add to the user", + "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..0ac36a9578 --- /dev/null +++ b/app/domain/factories/templates/core/v1/policy.rb @@ -0,0 +1,69 @@ +# 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 -%> + <% if defined?(annotations) -%> + annotations: + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + <% 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 to add to the policy", + "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..1294590823 --- /dev/null +++ b/app/domain/factories/templates/core/v1/user.rb @@ -0,0 +1,75 @@ +# 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 -%> + <% if defined?(annotations) -%> + annotations: + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + <% 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 to add to the user", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factory/templates/authenticators/authn_oidc.rb b/app/domain/factory/templates/authenticators/authn_oidc.rb new file mode 100644 index 0000000000..ca5cd15241 --- /dev/null +++ b/app/domain/factory/templates/authenticators/authn_oidc.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'base64' + +module Factory + module Templates + module Authenticators + class AuthnOidc + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= service_id %> + body: + - !webservice + + - !variable provider-uri + - !variable client-id + - !variable client-secret + + # URI of Conjur instance + - !variable redirect-uri + + # Defines the JWT claim to use as the Conjur identifier + - !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({ + policy: Base64.encode64(policy_template), + target_policy_namespace: "conjur/authn-oidc", + target_variable_namespace: "conjur/authn-oidc/<%= service_id %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Authn-OIDC Template", + "description": "Create a new Authn-OIDC Authenticator", + "type": "object", + "properties": { + "service-id": { + "description": "Service ID of the Authenticator", + "type": "string" + }, + "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[service-id] + } + }.to_json) + end + end + end + end + end +end diff --git a/app/domain/factory/templates/connections/database.rb b/app/domain/factory/templates/connections/database.rb new file mode 100644 index 0000000000..576c851eb6 --- /dev/null +++ b/app/domain/factory/templates/connections/database.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'base64' + +module Factory + module Templates + module Connections + class Database + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: connections/database + 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({ + policy: Base64.encode64(policy_template), + target_policy_namespace: "<%= branch %>", + target_variable_namespace: "<%= branch %>/<%= id %>", + 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 ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this database connection into", + "type": "string" + }, + "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 diff --git a/app/domain/responses.rb b/app/domain/responses.rb new file mode 100644 index 0000000000..2bb627392c --- /dev/null +++ b/app/domain/responses.rb @@ -0,0 +1,69 @@ +# 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 :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 message + case @message + when Array + @message + when Hash + @message + else + { message: @message.to_s } + end + end + + def to_h + { + code: HTTP::Response::Status::SYMBOL_CODES[@status] + }.tap do |rtn| + rtn[:error] = message + end + 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/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/ci/coverage-report-generator/Gemfile.lock b/ci/coverage-report-generator/Gemfile.lock index 46c55ec7fd..73a8ef94c8 100644 --- a/ci/coverage-report-generator/Gemfile.lock +++ b/ci/coverage-report-generator/Gemfile.lock @@ -17,4 +17,4 @@ DEPENDENCIES simplecov_json_formatter (= 0.1.4) BUNDLED WITH - 2.2.30 + 2.2.33 diff --git a/ci/coverage-report-generator/run.sh b/ci/coverage-report-generator/run.sh index 93abbd17ea..81793ed096 100755 --- a/ci/coverage-report-generator/run.sh +++ b/ci/coverage-report-generator/run.sh @@ -7,7 +7,7 @@ set -xeu IMAGE="ruby:3.0" -BUNDLER_VERSION="2.2.30" +BUNDLER_VERSION="2.2.33" REPO_ROOT=$(git rev-parse --show-toplevel) diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 38f4c67de1..ead0fead90 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -1,6 +1,20 @@ version: "3" services: pg: + 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 + + pg2: image: postgres:10.16 environment: # To avoid the following error: @@ -15,12 +29,26 @@ 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: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 + + testdb2: image: postgres:10.16 environment: # To avoid the following error: @@ -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_k8s/dev/Dockerfile.test b/ci/test_suites/authenticators_k8s/dev/Dockerfile.test index 50feb7cf5b..0faf393d7b 100644 --- a/ci/test_suites/authenticators_k8s/dev/Dockerfile.test +++ b/ci/test_suites/authenticators_k8s/dev/Dockerfile.test @@ -10,6 +10,7 @@ RUN apt-get update && \ add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") $(lsb_release -cs) stable" && \ apt-get update && \ apt-get install -y docker-ce && \ + apt-get install -y jq && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/ci/test_suites/authenticators_k8s/dev/dev_conjur_http_proxy.template.yaml b/ci/test_suites/authenticators_k8s/dev/dev_conjur_http_proxy.template.yaml index 3f7ec36e68..8a90518224 100644 --- a/ci/test_suites/authenticators_k8s/dev/dev_conjur_http_proxy.template.yaml +++ b/ci/test_suites/authenticators_k8s/dev/dev_conjur_http_proxy.template.yaml @@ -1,3 +1,4 @@ +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -15,39 +16,41 @@ spec: app: conjur-authn-k8s spec: containers: - - image: {{ CONJUR_TEST_AUTHN_K8S_TAG }} - imagePullPolicy: Always - name: conjur - command: ["conjurctl", "server"] - env: - - name: DATABASE_URL - value: postgres://postgres@postgres:5432/postgres - - name: CONJUR_ADMIN_PASSWORD - value: admin - - name: CONJUR_ACCOUNT - value: cucumber - - name: CONJUR_DATA_KEY - value: "{{ DATA_KEY }}" - - name: RAILS_ENV - value: test - # Enable coverage tracking. - - name: REQUIRE_SIMPLECOV - value: "true" - # Sleep after generating the coverage report to keep the pod alive - # so the report can be retrieved. - - name: SIMPLECOV_SLEEP - value: "true" - - name: WEB_CONCURRENCY - value: "0" - - name: RAILS_MAX_THREADS - value: "10" - - name: CONJUR_AUTHENTICATORS - value: authn-k8s/minikube - - name: https_proxy - value: 'http://tinyproxy:8888' - volumeMounts: - - mountPath: /run/authn-local - name: authn-local + - image: {{ CONJUR_TEST_AUTHN_K8S_TAG }} + imagePullPolicy: Always + name: conjur + command: ["conjurctl", "server"] + env: + - name: DATABASE_URL + value: postgres://postgres@postgres:5432/postgres + - name: CONJUR_ADMIN_PASSWORD + value: admin + - name: CONJUR_ACCOUNT + value: cucumber + - name: CONJUR_DATA_KEY + value: "{{ DATA_KEY }}" + - name: RAILS_ENV + value: test + # Enable coverage tracking. + - name: REQUIRE_SIMPLECOV + value: "true" + # Sleep after generating the coverage report to keep the pod alive + # so the report can be retrieved. + - name: SIMPLECOV_SLEEP + value: "true" + - name: WEB_CONCURRENCY + value: "0" + - name: RAILS_MAX_THREADS + value: "10" + - name: CONJUR_AUTHENTICATORS + value: authn-k8s/minikube + - name: https_proxy + value: 'http://tinyproxy:8888' + - name: KUBERNETES_SERVICE_HOST + value: kubernetes.default.svc + volumeMounts: + - mountPath: /run/authn-local + name: authn-local volumes: - name: authn-local emptyDir: @@ -86,3 +89,4 @@ spec: - name: tinyproxy image: {{TINYPROXY_TAG}} imagePullPolicy: Always + diff --git a/ci/test_suites/authenticators_k8s/entrypoint.sh b/ci/test_suites/authenticators_k8s/entrypoint.sh index 05b8d149a7..5e2727b5b9 100755 --- a/ci/test_suites/authenticators_k8s/entrypoint.sh +++ b/ci/test_suites/authenticators_k8s/entrypoint.sh @@ -155,6 +155,7 @@ function test_gke() { -e INVENTORY_TAG \ -e INVENTORY_BASE_TAG \ -e NGINX_TAG \ + -e TINYPROXY_TAG \ -e CONJUR_AUTHN_K8S_TEST_NAMESPACE \ -e USE_GKE_GCLOUD_AUTH_PLUGIN=True \ -e GCLOUD_CLUSTER_NAME \ diff --git a/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh b/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh index 6dcaef7db3..d01e8f1d52 100755 --- a/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh +++ b/ci/test_suites/authenticators_k8s/test_gke_entrypoint.sh @@ -23,6 +23,13 @@ ENDOFLINE export LOCAL_DEV_VOLUME function finish { + # shellcheck disable=SC2181 + if [ $? -eq 0 ]; then + echo "Test PASSED!!!!" + else + echo "Test FAILED!!!! Displaying Kubernetes Resources" + dump_pod_logs + fi echo 'Finishing' echo '-----' @@ -97,6 +104,30 @@ function finish { delete_image "$NGINX_TAG" set -e } + +function get_pod_names(){ + # get a list of pods except for Conjur + kubectl get pods -o json \ + |jq -r '.items[].metadata.name' | grep -v conjur-authn-k8s +} + +function get_pod_container_names(){ + pod="${1}" + kubectl get "pod/${pod}" -o json \ + |jq -r '.spec.containers[].name' +} + +function dump_pod_logs(){ + kubectl get pods + get_pod_names | while read -r podname; do + get_pod_container_names "${podname}" | while read -r container_name; do + echo -e "\n\n ======= Container Logs Pod:${podname} Container:${container_name} =======" + kubectl logs "${podname}" --container "${container_name}" || true + echo -e " ======= End of Container Logs Pod:${podname} Container:${container_name} =======" + done + done +} + trap finish EXIT export TEMPLATE_TAG=gke. @@ -146,6 +177,7 @@ function createNamespace() { fi kubectl create namespace "$CONJUR_AUTHN_K8S_TEST_NAMESPACE" + kubectl config set-context \ "$(kubectl config current-context)" \ "--namespace=$CONJUR_AUTHN_K8S_TEST_NAMESPACE" @@ -165,6 +197,7 @@ function pushDockerImages() { docker push "$INVENTORY_TAG" docker push "$INVENTORY_BASE_TAG" docker push "$NGINX_TAG" + docker push "$TINYPROXY_TAG" } function launchConjurMaster() { @@ -251,22 +284,34 @@ function runTests() { cucumber_tags_arg="--tags \"$CUCUMBER_FILTER_TAGS\"" fi + # Run standard k8s authenticator tests run_cucumber "--tags 'not @skip' --tags 'not @k8s_skip' --tags 'not @sni_fails' --tags 'not @sni_success' $cucumber_tags_arg" + + # Run k8s authenticator tests with an HTTP proxy + run_conjur_master "dev_conjur_http_proxy" --disable-k8s-api-dns + run_cucumber "--tags 'not @skip' --tags 'not @k8s_skip' --tags '@http_proxy'" } retrieve_pod() { - kubectl get pods -l "app=$1" -o=jsonpath='{.items[].metadata.name}' + # Return the most recent pod name + kubectl get pods \ + -l "app=$1" \ + --field-selector=status.phase!=Terminating \ + --sort-by=.metadata.creationTimestamp \ + --no-headers | + tail -n 1 | + awk '{print $1}' } function run_conjur_master() { - filename=$1 + filename=$1; shift sed -e "s#{{ CONJUR_AUTHN_K8S_TAG }}#$CONJUR_AUTHN_K8S_TAG#g" "dev/$filename.${TEMPLATE_TAG}yaml" | sed -e "s#{{ CONJUR_TEST_AUTHN_K8S_TAG }}#$CONJUR_TEST_AUTHN_K8S_TAG#g" | sed -e "s#{{ NGINX_TAG }}#$NGINX_TAG#g" | sed -e "s#{{ DATA_KEY }}#$DATA_KEY#g" | sed -e "s#{{ CONJUR_AUTHN_K8S_TEST_NAMESPACE }}#$CONJUR_AUTHN_K8S_TEST_NAMESPACE#g" | - kubectl create -f - + kubectl apply -f - # Turn off -e since we expect failures when retrieving pod before it's ready. set +e @@ -301,6 +346,19 @@ function run_conjur_master() { local wait_command="while ! curl --silent --head --fail \ localhost:80 > /dev/null; do sleep 1; done" kubectl exec "$conjur_pod" -- bash -c "$wait_command" + + # Handle flags + while [[ $# -gt 0 ]]; do + case "$1" in + --disable-k8s-api-dns) + # Add a host entry to blackhole the kubernetes API endpoint + kubectl exec "$conjur_pod" -- \ + bash -c "echo '0.0.0.0 kubernetes.default.svc' >> /etc/hosts" + shift ;; + *) + echo "Unknown option: $1" + esac + done } function run_cucumber() { 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..e6ba5ad073 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/config/routes.rb b/config/routes.rb index e1f4db4a66..702ed3277e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,6 +51,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/cucumber.yml b/cucumber.yml index 67b3d17cf5..f3c4378b5e 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 -r cucumber/api/features/support/logs_helpers.rb -r cucumber/api/features/step_definitions/logs_steps.rb @@ -20,6 +22,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 @@ -34,6 +37,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 @@ -50,6 +54,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 @@ -66,6 +71,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 @@ -87,6 +93,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 @@ -105,6 +112,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 @@ -123,8 +131,8 @@ 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 -r cucumber/api/features/step_definitions/request_steps.rb -r cucumber/api/features/support/step_def_transforms.rb @@ -148,8 +156,8 @@ authenticators_jwt: > # profiles need to be thought through better and refactored most likely. # rotators: > + --tags 'not @manual and @rotators' --format pretty - -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 @@ -161,8 +169,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 bc93541569..08d641f6fc 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -9,6 +9,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 edabb5a5db..5d47c8df2d 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 4e47f2f2ef..90c785d26c 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/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/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index 6c164a773d..46c551b9ee 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -5,6 +5,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/authenticators_jwt/features/authn_jwt_token_schema.feature b/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature index 2aecc56f61..cb422b2bd4 100644 --- a/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature +++ b/cucumber/authenticators_jwt/features/authn_jwt_token_schema.feature @@ -700,7 +700,7 @@ Feature: JWT Authenticator - Token Schema Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00104E Failed to validate claim: claim name '%@^#[{]}$~=-+_?.><&^@*@#*sdhj812ehd' does not match regular expression: '(?-mix:^[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*(\/[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*)*$)'.> + CONJ00104E Failed to validate claim: claim name '%@^#[{]}$~=-+_?.><&^@*@#*sdhj812ehd' does not match regular expression: '(?-mix:^[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*(\/[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*)*$)'.> """ @negative @acceptance @@ -732,7 +732,7 @@ Feature: JWT Authenticator - Token Schema Then the HTTP response status code is 401 And The following appears in the log after my savepoint: """ - CONJ00104E Failed to validate claim: claim name '%@^#&^[{]}$~=-+_?.><812ehd' does not match regular expression: '(?-mix:^[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*(\/[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*)*$)'. + CONJ00104E Failed to validate claim: claim name '%@^#&^[{]}$~=-+_?.><812ehd' does not match regular expression: '(?-mix:^[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*(\/[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*)*$)'. """ @acceptance @@ -867,6 +867,42 @@ Feature: JWT Authenticator - Token Schema cucumber:host:myapp successfully authenticated with authenticator authn-jwt service cucumber:webservice:conjur/authn-jwt/raw """ + @sanity + @acceptance + Scenario: ONYX-29842 - Claims With In-Line Namespaces. Single enforced claim - 200 OK + Given I extend the policy with: + """ + - !variable conjur/authn-jwt/raw/enforced-claims + + - !host + id: myapp-01 + annotations: + authn-jwt/raw/conjur.org/enforced-property: valid + + - !grant + role: !group conjur/authn-jwt/raw/hosts + 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-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-01" + } + """ + And I save my place in the log file + When I authenticate via authn-jwt with the JWT token + 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-01 successfully authenticated with authenticator authn-jwt service cucumber:webservice:conjur/authn-jwt/raw + """ + @sanity @acceptance Scenario: ONYX-13716 Claim Alias nested annotation - 200 OK diff --git a/cucumber/authenticators_jwt/features/authn_status_jwt.feature b/cucumber/authenticators_jwt/features/authn_status_jwt.feature index 76e13f6071..3953407b3f 100644 --- a/cucumber/authenticators_jwt/features/authn_status_jwt.feature +++ b/cucumber/authenticators_jwt/features/authn_status_jwt.feature @@ -1108,7 +1108,7 @@ Feature: JWT Authenticator - Status Check And I save my place in the log file When I GET "/authn-jwt/raw/cucumber/status" Then the HTTP response status code is 500 - And the authenticator status check fails with error "does not match regular expression: '(?-mix:^[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*(\/[a-zA-Z|$|_][a-zA-Z|$|_|0-9|.]*)*$)" + And the authenticator status check fails with error "does not match regular expression: '(?-mix:^[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*(\/[a-zA-Z|$|_][a-zA-Z|$|_|\-|0-9|.]*)*$)" @negative @acceptance Scenario Outline: ONYX-10958: claim-aliases configured with invalid value, 500 Error 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/authenticators_oidc/features/authn_oidc_v2.feature b/cucumber/authenticators_oidc/features/authn_oidc_v2.feature index d59053e6cb..a0e258f2fb 100644 --- a/cucumber/authenticators_oidc/features/authn_oidc_v2.feature +++ b/cucumber/authenticators_oidc/features/authn_oidc_v2.feature @@ -100,7 +100,7 @@ Feature: OIDC Authenticator V2 - Users can authenticate with OIDC authenticator And I fetch a code for username "alice" and password "alice" from "keycloak2" And I save my place in the audit log file And I authenticate via OIDC V2 with code and service-id "keycloak2" - Then user "alice" has been authorized by Conjur for 8 minutes + Then user "alice" has been authorized by Conjur for 60 minutes And I successfully GET "/secrets/cucumber/variable/test-variable" with authorized user And The following appears in the audit log after my savepoint: """ 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 e8742d5d59..2f61a26020 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -18,6 +18,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 18ce8c7089..576e9ff89d 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/design/proposals/policy-factories/factory-usage.puml b/design/proposals/policy-factories/factory-usage.puml new file mode 100644 index 0000000000..97f893d81d --- /dev/null +++ b/design/proposals/policy-factories/factory-usage.puml @@ -0,0 +1,52 @@ +@startuml +(*) --> "Identify Factory variable based on request params" +if "Does factory variable exist?" then + --> [yes] if "Can role load factory variable?" + --> [yes] if "Does factory variable have a value?" + --> [yes] "Load Factory" + --> "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" + --> (*) + else + --> [no] "[Error] Setting Variable(s) not Permitted" + endif + else + --> [no] " Return Policy response" + endif + else + --> [no] "[Error] Policy Creation not Permitted" + endif + else + --> [no] "[Error] Invalid Policy Namespace ERB" + endif + else + --> [no] "[Error] Invalid Policy ERB" + endif + else + --> [no] "[Error] Missing Values" + endif + else + --> [no] "[Error] Missing Keys" + endif + else + --> [no] "[Error] Malformed JSON" + endif + else + --> [no] "[Error] Factory Variable empty" + endif + else + --> [no] "[Error] Factory not available" + endif +else + --> [no] "[Error] Factory Variable not present" +endif +@enduml diff --git a/design/proposals/policy-factories/workflow-beta-activity.puml b/design/proposals/policy-factories/workflow-beta-activity.puml new file mode 100644 index 0000000000..baac746f20 --- /dev/null +++ b/design/proposals/policy-factories/workflow-beta-activity.puml @@ -0,0 +1,166 @@ +@startuml +(*) --> "Identify Factory variable based on request params" +if "Does factory variable exist?" then + --> [yes] if "Can role load factory variable?" + --> [yes] if "Does factory variable have a value?" + --> [yes] "Load Factory" + --> "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" + --> (*) + 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] " Return Policy 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 Policy 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 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 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 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] Malformed JSON" + 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] Factory Variable empty" + 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 Variable not present" + 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@enduml diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 88ab8e3539..98ca9589d7 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 diff --git a/dev/policies/authenticators/authn-oidc/identity-users.template.yml b/dev/policies/authenticators/authn-oidc/identity-users.template.yml new file mode 100644 index 0000000000..587af45811 --- /dev/null +++ b/dev/policies/authenticators/authn-oidc/identity-users.template.yml @@ -0,0 +1,6 @@ +- !user {{ IDENTITY_USER }} + +- !grant + members: + - !user {{ IDENTITY_USER }} + role: !group conjur/authn-oidc/identity/authenticatable diff --git a/dev/policies/authenticators/authn-oidc/identity.yml b/dev/policies/authenticators/authn-oidc/identity.yml new file mode 100644 index 0000000000..75f229bdbd --- /dev/null +++ b/dev/policies/authenticators/authn-oidc/identity.yml @@ -0,0 +1,38 @@ +- !policy + id: conjur/authn-oidc/identity + body: + - !webservice + + # Identity connection details + - !variable provider-uri + - !variable client-id + - !variable client-secret + + # URI of Conjur instance + - !variable redirect_uri + + # Defines the JWT claim to use as the Conjur identifier + - !variable claim-mapping + + # Group with permission to authenticate + - !group + id: authenticatable + annotations: + description: Users who can authenticate using this authenticator + + - !permit + role: !group authenticatable + privilege: [ read, authenticate ] + resource: !webservice + + # Create a web service for checking status + - !webservice status + + # Group of users who can check the status + - !group operators + + # Permit group to check the status + - !permit + role: !group operators + privilege: read + resource: !webservice status diff --git a/dev/policies/authenticators/authn-oidc/okta-users.yml b/dev/policies/authenticators/authn-oidc/okta-2-users.yml similarity index 100% rename from dev/policies/authenticators/authn-oidc/okta-users.yml rename to dev/policies/authenticators/authn-oidc/okta-2-users.yml diff --git a/dev/secrets.yml b/dev/secrets.yml new file mode 100644 index 0000000000..1382b2aede --- /dev/null +++ b/dev/secrets.yml @@ -0,0 +1,3 @@ +IDENTITY_CLIENT_ID: !var dev/identity/app/client-id +IDENTITY_CLIENT_SECRET: !var dev/identity/app/client-secret +IDENTITY_PROVIDER_URI: !var dev/identity/app/provider-uri diff --git a/dev/start b/dev/start index e06baedb56..43433099d0 100755 --- a/dev/start +++ b/dev/start @@ -26,21 +26,30 @@ enabled_authenticators="$default_authenticators" env_args=() # Services and authenticators to start, enabled by cmd line flags. -ENABLE_AUTHN_LDAP=false -ENABLE_AUTHN_IAM=false +ENABLE_AUDIT=false ENABLE_AUTHN_AZURE=false -ENABLE_AUTHN_OIDC=false ENABLE_AUTHN_GCP=false +ENABLE_AUTHN_IAM=false ENABLE_AUTHN_JWT=false +ENABLE_AUTHN_LDAP=false +ENABLE_AUTHN_OIDC=false ENABLE_OIDC_ADFS=false +ENABLE_OIDC_IDENTITY=false +ENABLE_OIDC_KEYCLOAK=false ENABLE_OIDC_OKTA=false ENABLE_ROTATORS=false -ENABLE_AUDIT=false +IDENTITY_USER="" + +declare -a required_envvars +required_envvars[identity]="IDENTITY_CLIENT_ID IDENTITY_CLIENT_SECRET IDENTITY_PROVIDER_URI" +required_envvars[azure]="AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID AZURE_RESOURCE_GROUP" main() { unset COMPOSE_PROJECT_NAME parse_options "$@" + check_env_vars + if ! docker info >/dev/null 2>&1; then echo "Docker does not seem to be running, run it first and retry" exit 1 @@ -63,6 +72,7 @@ main() { start_conjur_server init_ldap + init_iam init_azure init_gcp init_jwt @@ -83,30 +93,28 @@ the Conjur container. To start the application server, run: # conjurctl server Usage: start [options] + --audit Starts with the audit engine and database enabled + --authn-azure Starts with authn-azure/prod as authenticator + --authn-gcp Starts with authn-gcp as authenticator + --authn-iam Starts with authn-iam/prod as authenticator + --authn-jwt Starts with authn-jwt as authenticator --authn-ldap Starts OpenLDAP server and loads a demo policy to enable authentication via: '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 + --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. + --oidc-keycloak Starts with authn-oidc/keycloak and authn-oidc/keycloak2 as + available authenticators. Service authn-oidc/keycloak uses + AuthnOIDC V1 (based on ID token property validation), and + authn-oidc/keycloak2 uses AuthnOIDC V2 (based on the Authz + Code with PKCE flow). + --oidc-okta Adds to authn-oidc okta static env configuration --rotators Starts a cucumber and test postgres container. Drops you into the cucumber container. You then manually start 'conjurctl server' in another tab. - - --authn-iam Starts with authn-iam/prod as authenticator - - --authn-azure Starts with authn-azure/prod as authenticator - - --authn-oidc Starts with authn-oidc/keycloak as authenticator - - --authn-gcp Starts with authn-gcp as authenticator - - --authn-jwt Starts with authn-jwt as authenticator - - --oidc-adfs Adds to authn-oidc adfs static env configuration - - --oidc-okta Adds to authn-oidc okta static env configuration - - --audit Starts with the audit engine and database enabled - - -h, --help Shows this help message. EOF exit } @@ -114,17 +122,19 @@ EOF parse_options() { while true ; do case "$1" in - --authn-iam ) ENABLE_AUTHN_IAM=true ; shift ;; + --audit ) ENABLE_AUDIT=true ; shift ;; --authn-azure ) ENABLE_AUTHN_AZURE=true ; shift ;; - --authn-ldap ) ENABLE_AUTHN_LDAP=true ; shift ;; --authn-gcp ) ENABLE_AUTHN_GCP=true ; shift ;; - --authn-jwt ) ENABLE_AUTHN_JWT=true ; shift ;; - --authn-oidc ) ENABLE_AUTHN_OIDC=true ; shift ;; - --oidc-adfs ) ENABLE_OIDC_ADFS=true ; shift ;; - --oidc-okta ) ENABLE_OIDC_OKTA=true ; shift ;; - --rotators ) ENABLE_ROTATORS=true ; shift ;; - --audit ) ENABLE_AUDIT=true ; shift ;; + --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 ;; -h | --help ) print_help ; shift ;; + --identity-user ) IDENTITY_USER="$2" ; shift ; shift ;; + --oidc-adfs ) ENABLE_AUTHN_OIDC=true ; ENABLE_OIDC_ADFS=true ; shift ;; + --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 ;; + --rotators ) ENABLE_ROTATORS=true ; shift ;; * ) if [ -z "$1" ]; then break @@ -134,6 +144,36 @@ parse_options() { fi ;; esac done + + if [[ $ENABLE_OIDC_IDENTITY = true && -z "$IDENTITY_USER" ]]; then + echo "Flag --oidc-identity must be paired with --identity-user. See --help." + exit 1 + fi +} + +check_env_vars() { + envvars="" + + if [[ $ENABLE_OIDC_IDENTITY = true ]]; then + envvars="$envvars ${required_envvars[identity]}" + fi + if [[ $ENABLE_AUTHN_AZURE = true ]]; then + envvars="$envvars ${required_envvars[azure]}" + fi + + IFS=' ' read -r -a envvars <<< "$envvars" + + envvar_message="Required environment variables not set:" + for envvar in "${envvars[@]}"; do + if [[ -z "${!envvar}" ]]; then + envvar_message="$envvar_message $envvar" + fi + done + + if [[ "$envvar_message" =~ .*"IDENTITY".* ]]; then + echo "$envvar_message" + exit 1 + fi } client_load_policy() { @@ -151,18 +191,6 @@ client_add_secret() { conjur variable values add "$variable" "$value" } -configure_oidc_providers() { - # Start conjur again, since it is recreating by docker-compose because of - # dependency with keycloak - start_conjur_server - wait_for_keycloak_server - fetch_oidc_certificates - configure_oidc_authenticators - enable_oidc_authenticators - create_keycloak_users - echo "keycloak admin console url: http://0.0.0.0:7777/auth/admin" -} - start_conjur_server() { echo "Starting Conjur server" docker-compose exec -d conjur conjurctl server @@ -183,74 +211,112 @@ wait_for_conjur() { } configure_oidc_authenticators() { - # add variables' values for keycloak - echo "Setting keycloak variables values in conjur" - client_load_policy \ - "/src/conjur-server/ci/test_suites/authenticators_oidc/policy.yml" + if [[ $ENABLE_OIDC_KEYCLOAK = true ]]; then + setup_keycloak + fi - client_add_secret \ - "conjur/authn-oidc/keycloak/provider-uri" \ - "https://keycloak:8443/auth/realms/master" + if [[ $ENABLE_OIDC_OKTA = true ]]; then + setup_okta + fi - client_add_secret \ - "conjur/authn-oidc/keycloak/id-token-user-property" "preferred_username" + if [[ $ENABLE_OIDC_ADFS = true ]]; then + setup_adfs + fi - # Specific for Keycloak Authenticator (for full flow) - client_load_policy '/src/conjur-server/dev/policies/authenticators/authn-oidc/keycloak2.yml' - client_add_secret 'conjur/authn-oidc/keycloak2/provider-uri' 'https://keycloak:8443/auth/realms/master' - client_add_secret 'conjur/authn-oidc/keycloak2/client-id' 'conjurClient' - client_add_secret 'conjur/authn-oidc/keycloak2/client-secret' '1234' - client_add_secret 'conjur/authn-oidc/keycloak2/claim-mapping' 'email' - client_add_secret 'conjur/authn-oidc/keycloak2/nonce' 'af88d8f8ff6631fb4cf0' - client_add_secret 'conjur/authn-oidc/keycloak2/state' 'd38e57e0fd47ccffa9c2' - client_add_secret 'conjur/authn-oidc/keycloak2/redirect_uri' 'http://localhost:3000/authn-oidc/keycloak2/cucumber/authenticate' + if [[ $ENABLE_OIDC_IDENTITY = true ]]; then + setup_identity + fi +} - client_load_policy '/src/conjur-server/dev/policies/authenticators/authn-oidc/keycloak2-users.yml' +setup_keycloak() { + # Start keycloak docker-compose service + services+=(keycloak) + docker-compose up -d --no-deps "${services[@]}" + # Start conjur again, since it is recreating by docker-compose because of + # dependency with keycloak + start_conjur_server + wait_for_keycloak_server + fetch_keycloak_certificate - if [[ $ENABLE_OIDC_OKTA = true ]]; then - # Specific for new OIDC Authenticator - client_load_policy '/src/conjur-server/dev/policies/authenticators/authn-oidc/okta-2.yml' - client_add_secret 'conjur/authn-oidc/okta-2/provider-uri' 'https://dev-92899796.okta.com/oauth2/default' - client_add_secret 'conjur/authn-oidc/okta-2/client-id' '0oa3w3xig6rHiu9yT5d7' - client_add_secret 'conjur/authn-oidc/okta-2/client-secret' 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj' - client_add_secret 'conjur/authn-oidc/okta-2/claim-mapping' 'preferred_username' - client_add_secret 'conjur/authn-oidc/okta-2/nonce' '1656b4264b60af659fce' - client_add_secret 'conjur/authn-oidc/okta-2/state' '4f413476ef7e2395f0af' - client_add_secret 'conjur/authn-oidc/okta-2/redirect_uri' 'http://localhost:3000/authn-oidc/okta-2/cucumber/authenticate' - - client_load_policy '/src/conjur-server/dev/policies/authenticators/authn-oidc/okta-users.yml' - fi + # Create Conjur AuthnOIDC services and users + configure_oidc_v1 \ + 'keycloak' 'https://keycloak:8443/auth/realms/master' \ + 'preferred_username' 'ci/test_suites/authenticators_oidc/policy.yml' + configure_oidc_v2 \ + 'keycloak2' 'https://keycloak:8443/auth/realms/master' \ + 'conjurClient' '1234' 'email' - if [[ $ENABLE_OIDC_ADFS = true ]]; then - # add variables' values for ADFS - client_load_policy "/src/conjur-server/ci/authn-oidc/policy_adfs.yml" + # Create users in Keycloak + create_keycloak_users - client_add_secret \ - "conjur/authn-oidc/adfs/provider-uri" \ - "https://adfs4win2016.northeurope.cloudapp.azure.com" + echo "keycloak admin console url: http://0.0.0.0:7777/auth/admin" +} - client_add_secret \ - "conjur/authn-oidc/adfs/id-token-user-property" "unique_name" - fi +setup_okta() { + configure_oidc_v2 \ + 'okta-2' 'https://dev-92899796.okta.com/oauth2/default' \ + '0oa3w3xig6rHiu9yT5d7' 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj' \ + 'preferred_username' } -fetch_oidc_certificates() { - fetch_keycloak_certificate +setup_adfs() { + echo "Initialize ADFS certificate in conjur server" + docker-compose exec conjur \ + /src/conjur-server/dev/files/authn-oidc/adfs/fetchCertificate - if [[ $ENABLE_OIDC_ADFS = true ]]; then - echo "Initialize ADFS certificate in conjur server" - docker-compose exec conjur \ - /src/conjur-server/dev/files/authn-oidc/adfs/fetchCertificate - fi + configure_oidc_v1 \ + 'adfs' 'https://adfs4win2016.northeurope.cloudapp.azure.com' \ + 'unique_name' 'ci/authn-oidc/policy_adfs.yml' +} + +setup_identity() { + policy_dir="policies/authenticators/authn-oidc" + rm -f "$policy_dir/identity-users.yml" + sed -e "s#{{ IDENTITY_USER }}#$IDENTITY_USER#g" "$policy_dir/identity-users.template.yml" > "$policy_dir/identity-users.yml" + + configure_oidc_v2 \ + 'identity' "$IDENTITY_PROVIDER_URI" \ + "$IDENTITY_CLIENT_ID" "$IDENTITY_CLIENT_SECRET" \ + 'email' +} + +configure_oidc_v1() { + service_id="$1" + provider_uri="$2" + token_property="$3" + policy_path="$4" + + 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" +} + +configure_oidc_v2() { + service_id="$1" + provider_uri="$2" + client_id="$3" + client_secret="$4" + claim_mapping="$5" + + client_load_policy "/src/conjur-server/dev/policies/authenticators/authn-oidc/$service_id.yml" + client_add_secret "conjur/authn-oidc/$service_id/provider-uri" "$provider_uri" + client_add_secret "conjur/authn-oidc/$service_id/client-id" "$client_id" + 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" + + client_load_policy "/src/conjur-server/dev/policies/authenticators/authn-oidc/$service_id-users.yml" } enable_oidc_authenticators() { - echo "Configuring Keycloak as OpenID provider for automatic testing" - # We enable an OIDC authenticator without a service-id to test that it's - # invalid. - enabled_authenticators="$enabled_authenticators,authn-oidc/keycloak,authn-oidc,authn-oidc/keycloak2" + if [[ $ENABLE_OIDC_KEYCLOAK = true ]]; then + echo "Configuring Keycloak as OpenID provider for manual testing" + # We enable an OIDC authenticator without a service-id to test that it's + # invalid. + enabled_authenticators="$enabled_authenticators,authn-oidc/keycloak,authn-oidc,authn-oidc/keycloak2" + fi if [[ $ENABLE_OIDC_OKTA = true ]]; then echo "Configuring OKTA as OpenID provider for manual testing" @@ -261,6 +327,11 @@ enable_oidc_authenticators() { echo "Configuring ADFS as OpenID provider for manual testing" enabled_authenticators="$enabled_authenticators,authn-oidc/adfs" fi + + if [[ $ENABLE_OIDC_IDENTITY = true ]]; then + echo "Configuring Identity as OpenID provider for manual testing" + enabled_authenticators="$enabled_authenticators,authn-oidc/identity" + fi } init_data_key() { @@ -363,29 +434,20 @@ init_jwt() { # OIDC is a special case on JWT, JWT automation tests contain scenarios with # OIDC providers. - configure_oidc_providers + configure_oidc_authenticators + enable_oidc_authenticators echo "Configure jwks provider" docker-compose exec jwks "/tmp/create_nginx_certificate.sh" } init_oidc() { - # ADFS and OKTA make no sense without OIDC. - if [[ $ENABLE_AUTHN_OIDC = false && - ($ENABLE_OIDC_ADFS = true || $ENABLE_OIDC_OKTA = true) ]] - then - echo "Error: --oidc-adfs and --oidc-okta both require --authn-oidc" - exit 1 - fi - if [[ $ENABLE_AUTHN_OIDC != true ]]; then return fi - services+=(keycloak) - docker-compose up -d --no-deps "${services[@]}" - - configure_oidc_providers + configure_oidc_authenticators + enable_oidc_authenticators if [[ $ENABLE_AUTHN_LDAP = true && $ENABLE_OIDC_OKTA = true ]]; then echo "Building & configuring Okta-LDAP agent" diff --git a/docs/Dockerfile b/docs/Dockerfile index 5eef693a5d..fa474d1f2f 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -2,7 +2,7 @@ FROM jekyll/jekyll:4.0 ADD Gemfile Gemfile.lock /srv/jekyll/ -ENV BUNDLER_VERSION 2.2.30 +ENV BUNDLER_VERSION 2.2.33 RUN gem install bundler -v $BUNDLER_VERSION RUN bundle --without development diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 45958c5a5e..93168789b8 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -14,7 +14,7 @@ GEM execjs coffee-script-source (1.11.1) colorator (1.1.0) - commonmarker (0.23.8) + commonmarker (0.23.9) concurrent-ruby (1.2.2) dnsruby (1.61.9) simpleidn (~> 0.1) @@ -213,7 +213,7 @@ GEM jekyll-feed (~> 0.9) jekyll-seo-tag (~> 2.1) minitest (5.18.0) - nokogiri (1.14.2) + nokogiri (1.14.3) mini_portile2 (~> 2.8.0) racc (~> 1.4) octokit (4.25.1) 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 diff --git a/gems/conjur-rack/CHANGELOG.md b/gems/conjur-rack/CHANGELOG.md new file mode 100644 index 0000000000..2289ed3172 --- /dev/null +++ b/gems/conjur-rack/CHANGELOG.md @@ -0,0 +1,54 @@ +**This Gem has been moved into Conjur. All Conjur Rack Changelog entries should +appear in the main Changelog.** + +# unreleased version + +# v5.0.0 + +* Support Ruby 3. +* Bump `slosilo` to v3.0 with ruby 3. +* Remove pinned `bundler` version, use default system bundler. + +# v4.2.0 + +* Bump `slosilo` to v2.2 in order to be FIPS compliant + +# v4.0.0 + +* Bump `rack` to v2, `bundler` to v1.16 in gemspec +* Add Jenkinsfile to project +* Ignore headers such as Conjur-Privilege or Conjur-Audit if they're not +supported by the API (instead of erroring out). + +# v3.1.0 + +* Support for JWT Slosilo tokens. + +# v3.0.0.pre + +* Initial support for Conjur 5. + +# v2.3.0 + +* Add TRUSTED_PROXIES support + +# v2.2.0 + +* resolve 'own' token to CONJUR_ACCOUNT env var +* add #optional paths to Conjur::Rack authenticator + +# v2.1.0 + +* Add handling for `Conjur-Audit-Roles` and `Conjur-Audit-Resources` + +# v2.0.0 + +* Change `global_sudo?` to `global_elevate?` + +# v1.4.0 + +* Add `validated_global_privilege` helper function to get the global privilege, if any, which has been submitted with the request and verified by the Conjur server. + +# v1.3.0 + +* Add handling for `X-Forwarded-For` and `X-Conjur-Privilege` diff --git a/gems/conjur-rack/CONTRIBUTING.md b/gems/conjur-rack/CONTRIBUTING.md new file mode 100644 index 0000000000..7c0a67db14 --- /dev/null +++ b/gems/conjur-rack/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/conjur-rack/Gemfile b/gems/conjur-rack/Gemfile new file mode 100644 index 0000000000..ba44e6d927 --- /dev/null +++ b/gems/conjur-rack/Gemfile @@ -0,0 +1,12 @@ +source 'https://rubygems.org' + +# make sure github uses TLS +git_source(:github) { |name| "https://github.com/#{name}.git" } + +#ruby=ruby-3.0 +#ruby-gemset=conjur-rack + +# Specify your gem's dependencies in conjur-rack.gemspec +gemspec + +# gem 'conjur-api', github: 'cyberark/conjur-api-ruby', branch: 'master' diff --git a/gems/conjur-rack/LICENSE.txt b/gems/conjur-rack/LICENSE.txt new file mode 100644 index 0000000000..069db73dc1 --- /dev/null +++ b/gems/conjur-rack/LICENSE.txt @@ -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/conjur-rack/README.md b/gems/conjur-rack/README.md new file mode 100644 index 0000000000..98b50e1a96 --- /dev/null +++ b/gems/conjur-rack/README.md @@ -0,0 +1,27 @@ +# Conjur::Rack + +TODO: Write a gem description + +## Installation + +Add this line to your application's Gemfile: + + gem 'conjur-rack' + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install conjur-rack + +## Usage + +TODO: Write usage instructions here + +## 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/conjur-rack/Rakefile b/gems/conjur-rack/Rakefile new file mode 100644 index 0000000000..d0c3eb3141 --- /dev/null +++ b/gems/conjur-rack/Rakefile @@ -0,0 +1,14 @@ +require "bundler/gem_tasks" +require 'rspec/core/rake_task' +require 'ci/reporter/rake/rspec' + +RSpec::Core::RakeTask.new(:spec) do |t| + t.rspec_opts = "--format doc" + unless ENV["CONJUR_ENV"] == "ci" + t.rspec_opts << " --color" + else + Rake::Task["ci:setup:rspec"].invoke + end +end + +task :default => :spec diff --git a/gems/conjur-rack/conjur-rack.gemspec b/gems/conjur-rack/conjur-rack.gemspec new file mode 100644 index 0000000000..a4627220a4 --- /dev/null +++ b/gems/conjur-rack/conjur-rack.gemspec @@ -0,0 +1,28 @@ +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'conjur/rack/version' + +Gem::Specification.new do |spec| + spec.name = 'conjur-rack' + spec.version = Conjur::Rack::VERSION + spec.authors = ['Cyberark R&D'] + spec.summary = 'Rack authenticator and basic User struct' + spec.homepage = 'http://github.com/conjurinc/conjur-rack' + + spec.files = Dir.glob("lib/**/*") + %w[README.md] + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 2.5' + + spec.add_dependency('conjur-api', '< 6') + spec.add_dependency('rack', '~> 2') + spec.add_dependency('slosilo', '~> 3.0') + + spec.add_development_dependency('activesupport', '< 7') + spec.add_development_dependency('ci_reporter_rspec') + spec.add_development_dependency('pry-byebug') + spec.add_development_dependency('rake') + spec.add_development_dependency('rspec') + spec.add_development_dependency('rspec-its') +end diff --git a/gems/conjur-rack/lib/conjur/rack.rb b/gems/conjur-rack/lib/conjur/rack.rb new file mode 100644 index 0000000000..1a9c353baf --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack.rb @@ -0,0 +1,26 @@ +require "conjur/rack/version" +require "conjur/rack/authenticator" +require "conjur/rack/path_prefix" +require 'ipaddr' +require 'set' + +module TrustedProxies + + def trusted_proxy?(ip) + trusted_proxies ? trusted_proxies.any? { |cidr| cidr.include?(ip) } : super + end + + def trusted_proxies + @trusted_proxies || ENV['TRUSTED_PROXIES'].try do |proxies| + cidrs = Set.new(proxies.split(',') + ['127.0.0.1']) + @trusted_proxies = cidrs.collect {|cidr| IPAddr.new(cidr) } + end + end + +end + +module Rack + class Request + prepend TrustedProxies + end +end diff --git a/gems/conjur-rack/lib/conjur/rack/authenticator.rb b/gems/conjur-rack/lib/conjur/rack/authenticator.rb new file mode 100644 index 0000000000..902240983b --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack/authenticator.rb @@ -0,0 +1,196 @@ +require "conjur/rack/user" + +module Conjur + module Rack + + class << self + def conjur_rack + Thread.current[:conjur_rack] ||= {} + end + + def identity? + !conjur_rack[:identity].nil? + end + + def user + User.new(identity[0], identity[1], + :privilege => privilege, + :remote_ip => remote_ip, + :audit_roles => audit_roles, + :audit_resources => audit_resources + ) + end + + def identity + conjur_rack[:identity] or raise "No Conjur identity for current request" + end + + # class attributes + [:privilege, :remote_ip, :audit_roles, :audit_resources].each do |a| + define_method(a) do + conjur_rack[a] + end + end + end + + + class Authenticator + class AuthorizationError < SecurityError + end + class SignatureError < SecurityError + end + class Forbidden < SecurityError + end + + attr_reader :app, :options + + # +options+: + # :except :: a list of request path patterns for which to skip authentication. + # :optional :: request path patterns for which authentication is optional. + def initialize app, options = {} + @app = app + @options = options + end + + # threadsafe accessors, values are established explicitly below + def env; Thread.current[:rack_env] ; end + + # instance attributes + [:token, :account, :privilege, :remote_ip, :audit_roles, :audit_resources].each do |a| + define_method(a) do + conjur_rack[a] + end + end + + def call rackenv + # never store request-specific variables as application attributes + Thread.current[:rack_env] = rackenv + if authenticate? + begin + identity = verify_authorization_and_get_identity # [token, account] + + if identity + conjur_rack[:token] = identity[0] + conjur_rack[:account] = identity[1] + conjur_rack[:identity] = identity + conjur_rack[:privilege] = http_privilege + conjur_rack[:remote_ip] = http_remote_ip + conjur_rack[:audit_roles] = http_audit_roles + conjur_rack[:audit_resources] = http_audit_resources + end + + rescue Forbidden + return error 403, $!.message + rescue SecurityError, RestClient::Exception + return error 401, $!.message + end + end + begin + @app.call rackenv + ensure + Thread.current[:rack_env] = nil + Thread.current[:conjur_rack] = {} + end + end + + protected + + def conjur_rack + Conjur::Rack.conjur_rack + end + + def validate_token_and_get_account token + failure = SignatureError.new("Unauthorized: Invalid token") + raise failure unless (signer = Slosilo.token_signer token) + if signer == 'own' + ENV['CONJUR_ACCOUNT'] or raise failure + else + raise failure unless signer =~ /\Aauthn:(.+)\z/ + $1 + end + end + + def error status, message + [status, { 'Content-Type' => 'text/plain', 'Content-Length' => message.length.to_s }, [message] ] + end + + def parsed_token + token = http_authorization.to_s[/^Token token="(.*)"/, 1] + token = token && JSON.parse(Base64.decode64(token)) + token = Slosilo::JWT token rescue token + rescue JSON::ParserError + raise AuthorizationError.new("Malformed authorization token") + end + + RECOGNIZED_CLAIMS = [ + 'iat', 'exp', # recognized by Slosilo + 'cidr', 'sub', + 'iss', 'aud', 'jti' # RFC 7519, not handled but recognized + ].freeze + + def verify_authorization_and_get_identity + if token = parsed_token + begin + account = validate_token_and_get_account token + if token.respond_to?(:claims) + claims = token.claims + raise AuthorizationError, "token contains unrecognized claims" unless \ + (claims.keys.map(&:to_s) - RECOGNIZED_CLAIMS).empty? + if (cidr = claims['cidr']) + raise Forbidden, "IP address rejected" unless \ + cidr.map(&IPAddr.method(:new)).any? { |c| c.include? http_remote_ip } + end + end + return [token, account] + end + else + path = http_path + if optional_paths.find{|p| p.match(path)}.nil? + raise AuthorizationError.new("Authorization missing") + else + nil + end + end + end + + def authenticate? + path = http_path + if options[:except] + options[:except].find{|p| p.match(path)}.nil? + else + true + end + end + + def optional_paths + options[:optional] || [] + end + + def http_authorization + env['HTTP_AUTHORIZATION'] + end + + def http_privilege + env['HTTP_X_CONJUR_PRIVILEGE'] + end + + def http_remote_ip + require 'rack/request' + ::Rack::Request.new(env).ip + end + + def http_audit_roles + env['HTTP_CONJUR_AUDIT_ROLES'] + end + + def http_audit_resources + env['HTTP_CONJUR_AUDIT_RESOURCES'] + end + + def http_path + [ env['SCRIPT_NAME'], env['PATH_INFO'] ].join + end + + end + end +end diff --git a/gems/conjur-rack/lib/conjur/rack/path_prefix.rb b/gems/conjur-rack/lib/conjur/rack/path_prefix.rb new file mode 100644 index 0000000000..511db284d3 --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack/path_prefix.rb @@ -0,0 +1,31 @@ +# https://raw.github.com/merb/merb/master/merb-core/lib/merb-core/rack/middleware/path_prefix.rb +module Conjur + module Rack + class PathPrefix + EMPTY_STRING = "" + SLASH = "/" + + # @api private + def initialize(app, path_prefix = nil) + @app = app + @path_prefix = /^#{Regexp.escape(path_prefix)}/ + end + + # @api plugin + def call(env) + strip_path_prefix(env) + @app.call(env) + end + + # @api private + def strip_path_prefix(env) + ['PATH_INFO', 'REQUEST_URI'].each do |path_key| + if env[path_key] =~ @path_prefix + env[path_key].sub!(@path_prefix, EMPTY_STRING) + env[path_key] = SLASH if env[path_key].empty? + end + end + end + end + end +end \ No newline at end of file diff --git a/gems/conjur-rack/lib/conjur/rack/user.rb b/gems/conjur-rack/lib/conjur/rack/user.rb new file mode 100644 index 0000000000..9680d2ee5a --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack/user.rb @@ -0,0 +1,141 @@ +require 'conjur/api' + +module Conjur + module Rack + # Token data can be a string (which is the user login), or a Hash. + # If it's a hash, it should contain the user login keyed by the string 'login'. + # The rest of the payload is available as +attributes+. + class User + attr_reader :token, :account, :privilege, :remote_ip, :audit_roles, :audit_resources + + def initialize(token, account, options = {}) + @token = token + @account = account + # Third argument used to be the name of privilege, be + # backwards compatible: + if options.respond_to?(:to_str) + @privilege = options + else + @privilege = options[:privilege] + @remote_ip = options[:remote_ip] + @audit_roles = options[:audit_roles] + @audit_resources = options[:audit_resources] + end + end + + # This file was accidently calling account conjur_account, + # I'm adding an alias in case that's going on anywhere else. + # -- Jon + alias :conjur_account :account + # alias :conjur_account= :account= + + # Returns the global privilege which was present on the request, if and only + # if the user actually has that privilege. + # + # Returns nil if no global privilege was present in the request headers, + # or if a global privilege was present in the request headers, but the user doesn't + # actually have that privilege according to the Conjur server. + def validated_global_privilege + unless @validated_global_privilege + @privilege = nil unless @privilege && + api.respond_to?(:global_privilege_permitted?) && + api.global_privilege_permitted?(@privilege) + @validated_global_privilege = true + end + @privilege + end + + # True if and only if the user has valid global 'reveal' privilege. + def global_reveal? + validated_global_privilege == "reveal" + end + + # True if and only if the user has valid global 'elevate' privilege. + def global_elevate? + validated_global_privilege == "elevate" + end + + def login + parse_token + + @login + end + + def attributes + parse_token + + @attributes || {} + end + + def roleid + tokens = login.split('/') + role_kind, roleid = if tokens.length == 1 + [ 'user', login ] + else + [ tokens[0], tokens[1..-1].join('/') ] + end + [ account, role_kind, roleid ].join(':') + end + + def role + api.role(roleid) + end + + def audit_resources + Conjur::API.decode_audit_ids(@audit_resources) if @audit_resources + end + + def audit_roles + Conjur::API.decode_audit_ids(@audit_roles) if @audit_roles + end + + def api(cls = Conjur::API) + args = [ token ] + args.push remote_ip if remote_ip + api = cls.new_from_token(*args) + + # These are features not present in some API versions. + # Test for them and only apply if it makes sense. Ignore otherwise. + %i(privilege audit_resources audit_roles).each do |feature| + meth = "with_#{feature}".intern + if api.respond_to?(meth) && (value = send(feature)) + api = api.send meth, value + end + end + + api + end + + protected + + def parse_token + return if @login + + @token = Slosilo::JWT token + load_jwt token + rescue ArgumentError + if data = token['data'] + return load_legacy data + else + raise "malformed token" + end + end + + def load_legacy data + if data.is_a?(String) + @login = token['data'] + elsif data.is_a?(Hash) + @attributes = token['data'].clone + @login = @attributes.delete('login') or raise "No 'login' field in token data" + else + raise "Expecting String or Hash token data, got #{data.class.name}" + end + end + + def load_jwt jwt + @attributes = jwt.claims.merge (jwt.header || {}) # just pass all the info + @login = jwt.claims['sub'] or raise "No 'sub' field in claims" + end + end + end +end diff --git a/gems/conjur-rack/lib/conjur/rack/version.rb b/gems/conjur-rack/lib/conjur/rack/version.rb new file mode 100644 index 0000000000..ad0a8d043c --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack/version.rb @@ -0,0 +1,5 @@ +module Conjur + module Rack + VERSION = "5.0.0" + end +end diff --git a/gems/conjur-rack/spec/rack/authenticator_spec.rb b/gems/conjur-rack/spec/rack/authenticator_spec.rb new file mode 100644 index 0000000000..6c0a1907bb --- /dev/null +++ b/gems/conjur-rack/spec/rack/authenticator_spec.rb @@ -0,0 +1,181 @@ +require 'spec_helper' + +require 'conjur/rack/authenticator' + +describe Conjur::Rack::Authenticator do + include_context "with authenticator" + + describe "#call" do + context "to an unprotected path" do + let(:except) { [ /^\/foo/ ] } + let(:env) { { 'SCRIPT_NAME' => '', 'PATH_INFO' => '/foo/bar' } } + before { + options[:except] = except + expect(app).to receive(:call).with(env).and_return app + } + context "without authorization" do + it "proceeds" do + expect(call).to eq(app) + expect(Conjur::Rack.identity?).to be(false) + end + end + context "with authorization" do + include_context "with authorization" + it "ignores the authorization" do + expect(call).to eq(app) + expect(Conjur::Rack.identity?).to be(false) + end + end + end + + context "to a protected path" do + let(:env) { { 'SCRIPT_NAME' => '/pathname' } } + context "without authorization" do + it "returns a 401 error" do + expect(call).to return_http 401, "Authorization missing" + end + end + context "with Conjur authorization" do + include_context "with authorization" + + context "with CIDR restriction" do + let(:claims) { { 'sub' => 'test-user', 'cidr' => %w(192.168.2.0/24 2001:db8::/32) } } + let(:token) { Slosilo::JWT.new(claims) } + before do + allow(subject).to receive_messages \ + parsed_token: token, + http_remote_ip: remote_ip + end + + %w(10.0.0.2 fdda:5cc1:23:4::1f).each do |addr| + context "with address #{addr} out of range" do + let(:remote_ip) { addr } + it "returns 403" do + expect(call).to return_http 403, "IP address rejected" + end + end + end + + %w(192.168.2.3 2001:db8::22).each do |addr| + context "with address #{addr} in range" do + let(:remote_ip) { addr } + it "passes the request" do + expect(call.login).to eq 'test-user' + end + end + end + end + + context "of a valid token" do + it 'launches app' do + expect(app).to receive(:call).with(env).and_return app + expect(call).to eq(app) + end + end + context "of an invalid token" do + it "returns a 401 error" do + allow(Slosilo).to receive(:token_signer).and_return(nil) + expect(call).to return_http 401, "Unauthorized: Invalid token" + end + end + 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" + end + end + context "of 'own' token" do + it "returns ENV['CONJUR_ACCOUNT']" do + expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return("test-account") + 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" + end + end + end + + context "with junk in token" do + let(:env) { { 'HTTP_AUTHORIZATION' => 'Token token="open sesame"' } } + it "returns 401" do + expect(call).to return_http 401, "Malformed authorization token" + end + end + + context "with JSON junk in token" do + let(:env) { { 'HTTP_AUTHORIZATION' => 'Token token="eyJmb28iOiAiYmFyIn0="' } } + before do + allow(Slosilo).to receive(:token_signer).and_return(nil) + end + + it "returns 401" do + expect(call).to return_http 401, "Unauthorized: Invalid token" + end + end + end + context "to an optional path" do + let(:optional) { [ /^\/foo/ ] } + let(:env) { { 'SCRIPT_NAME' => '', 'PATH_INFO' => '/foo/bar' } } + before { + options[:optional] = optional + } + context "without authorization" do + it "proceeds" do + expect(app).to receive(:call) do |*args| + expect(Conjur::Rack.identity?).to be(false) + :done + end + expect(call).to eq(:done) + end + end + context "with authorization" do + include_context "with authorization" + it "processes the authorization" do + expect(app).to receive(:call) do |*args| + expect(Conjur::Rack.identity?).to be(true) + :done + end + expect(call).to eq(:done) + end + end + end + + RSpec::Matchers.define :return_http do |status, message| + match do |actual| + status, headers, body = actual + expect(status).to eq status + expect(headers).to eq "Content-Type" => "text/plain", "Content-Length" => message.length.to_s + expect(body.join).to eq message + end + end + end + + # protected internal methods + + describe '#verify_authorization_and_get_identity' do + it "accepts JWT tokens without CIDR restrictions" do + mock_jwt sub: 'user' + expect { subject.send :verify_authorization_and_get_identity }.to_not raise_error + end + + it "rejects JWT tokens with unrecognized claims" do + mock_jwt extra: 'field' + expect { subject.send :verify_authorization_and_get_identity }.to raise_error \ + Conjur::Rack::Authenticator::AuthorizationError + 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' + end + end +end diff --git a/gems/conjur-rack/spec/rack/path_prefix_spec.rb b/gems/conjur-rack/spec/rack/path_prefix_spec.rb new file mode 100644 index 0000000000..41abf9d153 --- /dev/null +++ b/gems/conjur-rack/spec/rack/path_prefix_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +require 'conjur/rack/path_prefix' + +describe Conjur::Rack::PathPrefix do + let(:app) { double(:app) } + let(:prefix) { "/api" } + let(:path_prefix) { Conjur::Rack::PathPrefix.new(app, prefix) } + let(:call) { path_prefix.call env } + let(:env) { + { + 'PATH_INFO' => path + } + } + + context "#call" do + context "/api/hosts" do + let(:path) { "/api/hosts" } + it "matches" do + expect(app).to receive(:call).with({ 'PATH_INFO' => '/hosts' }).and_return app + call + end + end + context "/api" do + let(:path) { "/api" } + it "doesn't erase the path completely" do + expect(app).to receive(:call).with({ 'PATH_INFO' => '/' }).and_return app + call + end + end + context "with non-matching prefix" do + let(:path) { "/hosts" } + it "doesn't match" do + expect(app).to receive(:call).with({ 'PATH_INFO' => '/hosts' }).and_return app + call + end + end + end + +end \ No newline at end of file diff --git a/gems/conjur-rack/spec/rack/user_spec.rb b/gems/conjur-rack/spec/rack/user_spec.rb new file mode 100644 index 0000000000..e1f138a429 --- /dev/null +++ b/gems/conjur-rack/spec/rack/user_spec.rb @@ -0,0 +1,221 @@ +require 'spec_helper' +require 'conjur/rack/user' + +describe Conjur::Rack::User do + let(:login){ 'admin' } + let(:token){ {'data' => login} } + let(:account){ 'acct' } + let(:privilege) { nil } + let(:remote_ip) { nil } + let(:audit_roles) { nil } + let(:audit_resources) { nil } + + subject(:user) { + described_class.new(token, account, + :privilege => privilege, + :remote_ip => remote_ip, + :audit_roles => audit_roles, + :audit_resources => audit_resources + ) + } + + it 'provides field accessors' do + expect(user.token).to eq token + expect(user.account).to eq account + expect(user.conjur_account).to eq account + expect(user.login).to eq login + end + + describe '#roleid' do + let(:login){ tokens.join('/') } + + context "when login contains one token" do + let(:tokens) { %w(foobar) } + + it "is expanded to account:user:token" do + expect(subject.roleid).to eq "#{account}:user:foobar" + end + end + + context "when login contains two tokens" do + let(:tokens) { %w(foo bar) } + + it "is expanded to account:first:second" do + expect(subject.roleid).to eq "#{account}:foo:bar" + end + end + + context "when login contains three tokens" do + let(:tokens) { %w(foo bar baz) } + + it "is expanded to account:first:second/third" do + expect(subject.roleid).to eq "#{account}:foo:bar/baz" + end + end + end + + describe '#role' do + let(:roleid){ 'the role id' } + let(:api){ double('conjur api') } + before do + allow(subject).to receive(:roleid).and_return roleid + allow(subject).to receive(:api).and_return api + end + + it 'passes roleid to api.role' do + expect(api).to receive(:role).with(roleid).and_return 'the role' + expect(subject.role).to eq('the role') + end + end + + describe "#global_reveal?" do + let(:api){ double "conjur-api" } + before { allow(subject).to receive(:api).and_return(api) } + + context "with global privilege" do + let(:privilege) { "reveal" } + + context "when not supported" do + before { expect(api).not_to respond_to :global_privilege_permitted? } + it "simply returns false" do + expect(subject.global_reveal?).to be false + end + end + + context "when supported" do + before do + allow(api).to receive(:global_privilege_permitted?).with('reveal') { true } + end + it "checks the API function global_privilege_permitted?" do + expect(subject.global_reveal?).to be true + # The result is cached + expect(api).not_to receive :global_privilege_permitted? + subject.global_reveal? + end + end + end + + context "without a global privilege" do + it "simply returns false" do + expect(subject.global_reveal?).to be false + end + end + end + + describe '#api' do + context "when given a class" do + let(:cls){ double('API class') } + it "calls cls.new_from_token with its token" do + expect(cls).to receive(:new_from_token).with(token).and_return 'the api' + expect(subject.api(cls)).to eq('the api') + end + end + + context 'when not given args' do + let(:api) { double :api } + before do + allow(Conjur::API).to receive(:new_from_token).with(token).and_return(api) + end + + it "builds the api from token" do + expect(subject.api).to eq api + end + + context "with remote_ip" do + let(:remote_ip) { "the-ip" } + it "passes the IP to the API constructor" do + expect(Conjur::API).to receive(:new_from_token).with(token, 'the-ip').and_return(api) + expect(subject.api).to eq api + end + end + + context "with privilege" do + let(:privilege) { "elevate" } + it "applies the privilege on the API object" do + expect(api).to receive(:with_privilege).with("elevate").and_return "privileged api" + expect(subject.api).to eq "privileged api" + end + end + + context "when audit supported" do + before do + # If we're testing on an API version that doesn't + # support audit this method will be missing, so stub. + unless Conjur::API.respond_to? :decode_audit_ids + # not exactly a faithful reimplementation, but good enough for here + allow(Conjur::API).to receive(:decode_audit_ids) {|x|[x]} + end + end + + context "with audit resource" do + let (:audit_resources) { 'food:bacon' } + it "applies the audit resource on the API object" do + expect(api).to receive(:with_audit_resources).with(['food:bacon']).and_return('the api') + expect(subject.api).to eq 'the api' + end + end + + context "with audit roles" do + let (:audit_roles) { 'user:cook' } + it "applies the audit role on the API object" do + expect(api).to receive(:with_audit_roles).with(['user:cook']).and_return('the api') + expect(subject.api).to eq 'the api' + end + end + end + + context "when audit not supported" do + before do + expect(api).not_to respond_to :with_audit_resources + expect(api).not_to respond_to :with_audit_roles + end + let (:audit_resources) { 'food:bacon' } + let (:audit_roles) { 'user:cook' } + it "ignores audit roles and resources" do + expect(subject.api).to eq api + end + end + end + end + + context "with invalid type payload" do + let(:token){ { "data" => :alice } } + it "raises an error on trying to access the content" do + expect{ subject.login }.to raise_error("Expecting String or Hash token data, got Symbol") + end + end + + context "with hash payload" do + let(:token){ { "data" => { "login" => "alice", "capabilities" => { "fry" => "bacon" } } } } + + it "processes the login and attributes" do + original_token = token.deep_dup + + expect(subject.login).to eq('alice') + expect(subject.attributes).to eq({"capabilities" => { "fry" => "bacon" }}) + + expect(token).to eq original_token + end + end + + context "with JWT token" do + let(:token) { {"protected"=>"eyJhbGciOiJ0ZXN0IiwidHlwIjoiSldUIn0=", + "payload"=>"eyJzdWIiOiJhbGljZSIsImlhdCI6MTUwNDU1NDI2NX0=", + "signature"=>"dGVzdHNpZw=="} } + + it "processes the login and attributes" do + original_token = token.deep_dup + + expect(subject.login).to eq('alice') + + # TODO: should we only pass unrecognized attrs here? + expect(subject.attributes).to eq \ + 'alg' => 'test', + 'iat' => 1504554265, + 'sub' => 'alice', + 'typ' => 'JWT' + + expect(token).to eq original_token + end + end +end diff --git a/gems/conjur-rack/spec/rack_spec.rb b/gems/conjur-rack/spec/rack_spec.rb new file mode 100644 index 0000000000..02464cb21f --- /dev/null +++ b/gems/conjur-rack/spec/rack_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' +require 'conjur/rack' + +describe Conjur::Rack do + describe '.user' do + include_context "with authorization" + let(:stubuser) { double :stubuser } + before do + allow(Conjur::Rack::User).to receive(:new) + .with(token, 'someacc', {:privilege => privilege, :remote_ip => remote_ip, :audit_roles => audit_roles, :audit_resources => audit_resources}) + .and_return(stubuser) + end + + context 'when called in app context' do + shared_examples_for :returns_user do + it "returns user built from token" do + expect(call).to eq stubuser + end + end + + include_examples :returns_user + + context 'with X-Conjur-Privilege' do + let(:privilege) { "elevate" } + include_examples :returns_user + end + + context 'with X-Forwarded-For' do + let(:remote_ip) { "66.0.0.1" } + include_examples :returns_user + end + + context 'with Conjur-Audit-Roles' do + let (:audit_roles) { 'user%3Acook' } + include_examples :returns_user + end + + context 'with Conjur-Audit-Resources' do + let (:audit_resources) { 'food%3Abacon' } + include_examples :returns_user + end + + end + + it "raises error if called out of app context" do + expect { Conjur::Rack.user }.to raise_error('No Conjur identity for current request') + end + end +end diff --git a/gems/conjur-rack/spec/spec_helper.rb b/gems/conjur-rack/spec/spec_helper.rb new file mode 100644 index 0000000000..e35d2880d5 --- /dev/null +++ b/gems/conjur-rack/spec/spec_helper.rb @@ -0,0 +1,47 @@ +require 'rubygems' +$:.unshift File.join(File.dirname(__FILE__), "..", "lib") +$:.unshift File.join(File.dirname(__FILE__), "lib") + +# Allows loading of an environment config based on the environment +require 'rspec' +require 'rspec/its' +require 'securerandom' +require 'slosilo' + +RSpec.configure do |config| +end + +RSpec.shared_context "with authenticator" do + let(:options) { {} } + let(:app) { double(:app) } + subject(:authenticator) { Conjur::Rack::Authenticator.new(app, options) } + let(:call) { authenticator.call env } +end + +RSpec.shared_context "with authorization" do + include_context "with authenticator" + let(:token_signer) { "authn:someacc" } + let(:audit_resources) { nil } + let(:privilege) { nil } + let(:remote_ip) { nil } + let(:audit_roles) { nil } + + before do + allow(app).to receive(:call) { Conjur::Rack.user } + allow(Slosilo).to receive(:token_signer).and_return(token_signer) + end + + let(:env) do + { + 'HTTP_AUTHORIZATION' => "Token token=\"#{basic_64}\"" + }.tap do |e| + e['HTTP_X_CONJUR_PRIVILEGE'] = privilege if privilege + e['HTTP_X_FORWARDED_FOR'] = remote_ip if remote_ip + e['HTTP_CONJUR_AUDIT_ROLES'] = audit_roles if audit_roles + e['HTTP_CONJUR_AUDIT_RESOURCES'] = audit_resources if audit_resources + end + end + + let(:basic_64) { Base64.strict_encode64(token.to_json) } + let(:token) { { "data" => "foobar" } } +end diff --git a/gems/conjur-rack/test.sh b/gems/conjur-rack/test.sh new file mode 100755 index 0000000000..ad12ad8cf4 --- /dev/null +++ b/gems/conjur-rack/test.sh @@ -0,0 +1,12 @@ +#!/bin/bash -e + +TEST_IMAGE='ruby:3.0' + +rm -f Gemfile.lock + +docker run --rm \ + -v "$PWD:/usr/src/app" \ + -w /usr/src/app \ + -e CONJUR_ENV=ci \ + $TEST_IMAGE \ + bash -c "gem update --system && bundle update && bundle exec rake spec" diff --git a/gems/policy-parser/Dockerfile.test b/gems/policy-parser/Dockerfile.test index 9b7d802e50..c5ac281ed0 100644 --- a/gems/policy-parser/Dockerfile.test +++ b/gems/policy-parser/Dockerfile.test @@ -8,6 +8,6 @@ COPY conjur-policy-parser.gemspec conjur-policy-parser.gemspec COPY lib/conjur-policy-parser-version.rb lib/conjur-policy-parser-version.rb # Make sure the expected version of Bundler is available -ENV BUNDLER_VERSION=2.2.30 +ENV BUNDLER_VERSION=2.2.33 RUN gem install bundler -v ${BUNDLER_VERSION} && \ bundle install diff --git a/gems/policy-parser/conjur-policy-parser.gemspec b/gems/policy-parser/conjur-policy-parser.gemspec index 2e8c5d09cd..f727996d7b 100644 --- a/gems/policy-parser/conjur-policy-parser.gemspec +++ b/gems/policy-parser/conjur-policy-parser.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |spec| spec.add_dependency("activesupport", ">= 4.2") spec.add_dependency("safe_yaml") - spec.add_development_dependency("bundler", "~> 2.2.30") + spec.add_development_dependency("bundler", "~> 2.2.33") spec.add_development_dependency("ci_reporter_rspec") spec.add_development_dependency("deepsort") spec.add_development_dependency("pry") 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/out/app/domain/factory/Readme/Factory Info Request.png b/out/app/domain/factory/Readme/Factory Info Request.png new file mode 100644 index 0000000000..f7fe2a9972 Binary files /dev/null and b/out/app/domain/factory/Readme/Factory Info Request.png differ diff --git a/out/app/domain/factory/Readme/Readme-1.png b/out/app/domain/factory/Readme/Readme-1.png new file mode 100644 index 0000000000..6b577599b4 Binary files /dev/null and b/out/app/domain/factory/Readme/Readme-1.png differ diff --git a/out/app/domain/factory/Readme/Readme.png b/out/app/domain/factory/Readme/Readme.png new file mode 100644 index 0000000000..f7fe2a9972 Binary files /dev/null and b/out/app/domain/factory/Readme/Readme.png differ diff --git a/out/app/domain/factory/Readme/factory-create-request.png b/out/app/domain/factory/Readme/factory-create-request.png new file mode 100644 index 0000000000..6b577599b4 Binary files /dev/null and b/out/app/domain/factory/Readme/factory-create-request.png differ diff --git a/out/app/domain/factory/Readme/factory-info-request.png b/out/app/domain/factory/Readme/factory-info-request.png new file mode 100644 index 0000000000..f7fe2a9972 Binary files /dev/null and b/out/app/domain/factory/Readme/factory-info-request.png differ diff --git a/out/app/domain/factory/Readme/factory-list-request.png b/out/app/domain/factory/Readme/factory-list-request.png new file mode 100644 index 0000000000..7341126b0f Binary files /dev/null and b/out/app/domain/factory/Readme/factory-list-request.png differ diff --git a/out/app/domain/factory/Readme/factory-setup.png b/out/app/domain/factory/Readme/factory-setup.png new file mode 100644 index 0000000000..ddd64df3d4 Binary files /dev/null and b/out/app/domain/factory/Readme/factory-setup.png differ diff --git a/out/app/domain/factory/Readme/factory-upgrade.png b/out/app/domain/factory/Readme/factory-upgrade.png new file mode 100644 index 0000000000..74e2570a59 Binary files /dev/null and b/out/app/domain/factory/Readme/factory-upgrade.png differ diff --git a/out/design/proposals/policy-factories/factory-usage/factory-usage.png b/out/design/proposals/policy-factories/factory-usage/factory-usage.png new file mode 100644 index 0000000000..584360c2fc Binary files /dev/null and b/out/design/proposals/policy-factories/factory-usage/factory-usage.png differ diff --git a/out/design/proposals/policy-factories/workflow-beta-activity/workflow-beta-activity.png b/out/design/proposals/policy-factories/workflow-beta-activity/workflow-beta-activity.png new file mode 100644 index 0000000000..b2974706e1 Binary files /dev/null and b/out/design/proposals/policy-factories/workflow-beta-activity/workflow-beta-activity.png differ diff --git a/out/design/proposals/policy-factories/workflow/workflow.png b/out/design/proposals/policy-factories/workflow/workflow.png new file mode 100644 index 0000000000..b2974706e1 Binary files /dev/null and b/out/design/proposals/policy-factories/workflow/workflow.png differ diff --git a/publish-images.sh b/publish-images.sh index 69abfc7ee0..be59e77ed7 100755 --- a/publish-images.sh +++ b/publish-images.sh @@ -142,6 +142,9 @@ if [[ "${REDHAT}" = true ]]; then # scan image with preflight tool scan_redhat_image "${REDHAT_REMOTE_IMAGE}:${VERSION}" "${REDHAT_CERT_PID}" + + # push latest tag to RH + tag_and_push "latest" "${RH_LOCAL_IMAGE}" "${REDHAT_REMOTE_IMAGE}" else echo 'Failed to log in to quay.io' exit 1 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..45d9280267 --- /dev/null +++ b/spec/app/db/repository/policy_factory_repository_spec.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(DB::Repository::PolicyFactoryRepository) do + 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] + ) + # binding.pry + 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 + 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:admin') + ::Resource.create(resource_id: resource_id, owner: admin) + end + after(:each) do + ::Role[role_id].destroy + ::Resource[resource_id].destroy + ::Role['rspec:user: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 + end + end + end +end diff --git a/spec/app/domain/authentication/authn-jwt/identity_providers/identity_from_decoded_token_provider_spec.rb b/spec/app/domain/authentication/authn-jwt/identity_providers/identity_from_decoded_token_provider_spec.rb index c99cccedad..7fc1c8e6d1 100644 --- a/spec/app/domain/authentication/authn-jwt/identity_providers/identity_from_decoded_token_provider_spec.rb +++ b/spec/app/domain/authentication/authn-jwt/identity_providers/identity_from_decoded_token_provider_spec.rb @@ -12,6 +12,7 @@ let(:token_app_property_secret_value_is_hash) { 'nested' } let(:token_app_property_nested_from_hash_value) { 'nested/single' } let(:token_app_property_nested_from_array_value) { 'nested/array[0]' } + let(:token_app_property_namespaced) { 'namespaced.com/key' } let(:decoded_token) { { "namespace_id" => "1", @@ -36,7 +37,8 @@ "nested" => { "single" => "n_value", "array" => %w[a_value_1 a_value_2 a_value_3] - } + }, + "namespaced.com/key" => "namespaced-value" } } @@ -85,6 +87,12 @@ } } + let(:mocked_valid_secret_namespaced) { + { + "token-app-property" => token_app_property_namespaced + } + } + let(:mocked_valid_secrets_which_missing_in_token) { { "token-app-property" => "missing" @@ -98,13 +106,14 @@ let(:mocked_resource) { double("MockedResource") } let(:non_existing_field_name) { "non existing field name" } - let(:mocked_fetch_authenticator_secrets_exist_values) { double("MochedFetchAuthenticatorSecrets") } - let(:mocked_fetch_authenticator_secrets_value_points_to_array) { double("MochedFetchAuthenticatorSecretsPointsToArray") } - let(:mocked_fetch_authenticator_secrets_value_points_to_hash) { double("MochedFetchAuthenticatorSecretsPointsToHash") } - let(:mocked_fetch_authenticator_secrets_value_hash) { double("MochedFetchAuthenticatorSecretsHash") } - let(:mocked_fetch_authenticator_secrets_value_array) { double("MochedFetchAuthenticatorSecretsArray") } - let(:mocked_fetch_authenticator_secrets_which_missing_in_token) { double("MochedFetchAuthenticatorSecrets") } - let(:mocked_fetch_authenticator_secrets_empty_values) { double("MochedFetchAuthenticatorSecrets") } + let(:mocked_fetch_authenticator_secrets_exist_values) { double("MockedFetchAuthenticatorSecrets") } + let(:mocked_fetch_authenticator_secrets_value_points_to_array) { double("MockedFetchAuthenticatorSecretsPointsToArray") } + let(:mocked_fetch_authenticator_secrets_value_points_to_hash) { double("MockedFetchAuthenticatorSecretsPointsToHash") } + let(:mocked_fetch_authenticator_secrets_value_hash) { double("MockedFetchAuthenticatorSecretsHash") } + let(:mocked_fetch_authenticator_secrets_value_array) { double("MockedFetchAuthenticatorSecretsArray") } + let(:mocked_fetch_authenticator_secrets_value_namespaced) { double("MockedFetchAuthenticatorSecretsNamespaced") } + let(:mocked_fetch_authenticator_secrets_which_missing_in_token) { double("MockedFetchAuthenticatorSecrets") } + let(:mocked_fetch_authenticator_secrets_empty_values) { double("MockedFetchAuthenticatorSecrets") } let(:required_secret_missing_error) { "required secret missing error" } let(:required_identity_path_secret_missing_error) { "required secret missing error" } let(:mocked_fetch_required_secrets_token_app_with_value_identity_path_empty) { double("MockedFetchRequiredSecrets") } @@ -130,6 +139,11 @@ ::Authentication::AuthnJwt::PATH_DELIMITER + "a_value_1" } + let(:valid_jwt_identity_from_namespaced_claim) { + ::Authentication::AuthnJwt::IDENTITY_TYPE_HOST + + ::Authentication::AuthnJwt::PATH_DELIMITER + + "namespaced-value" + } let(:valid_jwt_identity_with_path) { ::Authentication::AuthnJwt::IDENTITY_TYPE_HOST + ::Authentication::AuthnJwt::PATH_DELIMITER + @@ -171,6 +185,10 @@ receive(:call).and_return(mocked_valid_secret_array) ) + allow(mocked_fetch_authenticator_secrets_value_namespaced).to( + receive(:call).and_return(mocked_valid_secret_namespaced) + ) + allow(mocked_fetch_authenticator_secrets_which_missing_in_token).to( receive(:call).and_return(mocked_valid_secrets_which_missing_in_token) ) @@ -360,6 +378,22 @@ end end + context "And 'identity-path' resource not exists, token-app-property with in-line namespace" do + subject do + ::Authentication::AuthnJwt::IdentityProviders::IdentityFromDecodedTokenProvider.new( + check_authenticator_secret_exists: mocked_authenticator_secret_exists, + fetch_authenticator_secrets: mocked_fetch_authenticator_secrets_value_namespaced, + fetch_identity_path: mocked_fetch_identity_path_valid_empty_path + ).call( + jwt_authenticator_input: jwt_authenticator_input + ) + end + + it "jwt_identity returns host identity" do + expect(subject).to eql(valid_jwt_identity_from_namespaced_claim) + end + end + context "And 'identity-path' resource exists with value" do subject do ::Authentication::AuthnJwt::IdentityProviders::IdentityFromDecodedTokenProvider.new( diff --git a/spec/app/domain/authentication/authn-jwt/input_validation/parse_claim_aliases_spec.rb b/spec/app/domain/authentication/authn-jwt/input_validation/parse_claim_aliases_spec.rb index d365ed6448..82f6925d42 100644 --- a/spec/app/domain/authentication/authn-jwt/input_validation/parse_claim_aliases_spec.rb +++ b/spec/app/domain/authentication/authn-jwt/input_validation/parse_claim_aliases_spec.rb @@ -120,15 +120,15 @@ end end - context "When input has illegal - character in claim name" do + context "When input has legal - character in claim name" do subject do ::Authentication::AuthnJwt::InputValidation::ParseClaimAliases.new().call( claim_aliases: "my-claim:a" ) end - it "raises an error" do - expect { subject }.to raise_error(Errors::Authentication::AuthnJwt::ClaimAliasInvalidClaimFormat) + it "does not raise an error" do + expect { subject }.not_to raise_error end end diff --git a/spec/app/domain/authentication/authn-jwt/input_validation/validate_claim_name_spec.rb b/spec/app/domain/authentication/authn-jwt/input_validation/validate_claim_name_spec.rb index 8b8195a4e9..2bed04b6e5 100644 --- a/spec/app/domain/authentication/authn-jwt/input_validation/validate_claim_name_spec.rb +++ b/spec/app/domain/authentication/authn-jwt/input_validation/validate_claim_name_spec.rb @@ -28,7 +28,6 @@ "When claim name is 1 dot character '.'": [".", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When claim name is just 1 forbidden character '*'": ["*", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When claim name contains 1 forbidden character '*'": ["a*b", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], - "When claim name contains 1 forbidden character '-": ["a-b", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When claim name contains 1 forbidden character '%'": ["a%b", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When claim name contains 1 forbidden character '!'": ["a!b", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When claim name contains 1 forbidden character '('": ["a(b", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], @@ -44,7 +43,6 @@ "When claim name contains spaces": ["claim name", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When input has illegal [ character in claim name": ["my[claim", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When input has illegal [ ] characters in claim name": ["my[1]claim", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], - "When input has illegal - character in claim name": ["my-claim", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName], "When input has illegal : character in claim name": ["a:", Errors::Authentication::AuthnJwt::FailedToValidateClaimForbiddenClaimName] } @@ -53,6 +51,7 @@ "When claim name contains 1 allowed char 'f'": "f", "When claim name contains 1 allowed char '_'": "_", "When claim name contains value with allowed char '/'": "a/a", + "When claim name contains value with allowed char '-'": "a-b", "When claim name contains value with multiple allowed chars '/'": "a/a/a/a", "When claim name contains 1 allowed char '$'": "$", "When claim name contains digits in the middle": "$2w", diff --git a/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restriction_name_spec.rb b/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restriction_name_spec.rb index d724e340af..cfdd629ab3 100644 --- a/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restriction_name_spec.rb +++ b/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restriction_name_spec.rb @@ -19,6 +19,8 @@ Authentication::ResourceRestrictions::ResourceRestriction.new(name: "x.k8s", value: "val"), "annotation with _ in the name": Authentication::ResourceRestrictions::ResourceRestriction.new(name: "project_id", value: "val"), + "- in annotation": + Authentication::ResourceRestrictions::ResourceRestriction.new(name: "project-id", value: "val") } invalid_cases = { @@ -30,8 +32,6 @@ Authentication::ResourceRestrictions::ResourceRestriction.new(name: "a[2]/c", value: "val"), "Array element Access": Authentication::ResourceRestrictions::ResourceRestriction.new(name: "a/b/c[2]", value: "val"), - "- in annotation": - Authentication::ResourceRestrictions::ResourceRestriction.new(name: "project-id", value: "val"), ": in annotation": Authentication::ResourceRestrictions::ResourceRestriction.new(name: "project:id", value: "val") } diff --git a/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restrictions_one_to_one_spec.rb b/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restrictions_one_to_one_spec.rb index 3415ad9ac5..9cd505581d 100644 --- a/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restrictions_one_to_one_spec.rb +++ b/spec/app/domain/authentication/authn-jwt/restriction_validation/validate_restrictions_one_to_one_spec.rb @@ -11,6 +11,7 @@ let(:spaced_email) { " " } let(:right_login) { "cucumber" } let(:wrong_login) { "tomato" } + let(:namespaced_value) { "some-value" } let(:decoded_token) { { @@ -35,6 +36,7 @@ "team_name" => "myteam", "team_id" => "team76" }, + "namespaced/inline" => "some-value", "iat" => 1619352275, "nbf" => 1619352270, "exp" => 1619355875, @@ -77,6 +79,10 @@ Authentication::ResourceRestrictions::ResourceRestriction.new(name: "additional_data/namespace", value: wrong_email) } + let(:existing_namespaced_inline_restriction) { + Authentication::ResourceRestrictions::ResourceRestriction.new(name: "namespaced/inline", value: namespaced_value) + } + let(:empty_annotation_restriction) { Authentication::ResourceRestrictions::ResourceRestriction.new(name: "not_existing", value: "") } @@ -140,6 +146,10 @@ ) end + it "returns true when the restriction is for namespaced field and its value equals the token" do + expect(subject.valid_restriction?(existing_namespaced_inline_restriction)).to eql(true) + end + it "raises EmptyAnnotationGiven when annotation is empty" do expect { subject.valid_restriction?(empty_annotation_restriction) }.to raise_error(Errors::Authentication::ResourceRestrictions::EmptyAnnotationGiven) 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 9c2d983c4f..9fdf0190d6 100644 --- a/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/v2/client_spec.rb @@ -3,22 +3,18 @@ require 'spec_helper' RSpec.describe(Authentication::AuthnOidc::V2::Client) do - let(:authenticator) do - Authentication::AuthnOidc::V2::DataObjects::Authenticator.new( - provider_uri: 'https://dev-92899796.okta.com/oauth2/default', - redirect_uri: 'http://localhost:3000/authn-oidc/okta-2/cucumber/authenticate', - client_id: '0oa3w3xig6rHiu9yT5d7', - client_secret: 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj', - claim_mapping: 'foo', - account: 'bar', - service_id: 'baz' - ) - end - - let(:client) do - VCR.use_cassette("authenticators/authn-oidc/v2/client_load") do + def client(config) + VCR.use_cassette("authenticators/authn-oidc/v2/#{config[:service_id]}/client_load") do client = Authentication::AuthnOidc::V2::Client.new( - authenticator: authenticator + authenticator: Authentication::AuthnOidc::V2::DataObjects::Authenticator.new( + provider_uri: config[:provider_uri], + redirect_uri: "http://localhost:3000/authn-oidc/#{config[:service_id]}/cucumber/authenticate", + client_id: config[:client_id], + client_secret: config[:client_secret], + claim_mapping: "email", + account: "bar", + service_id: "baz" + ) ) # The call `oidc_client` queries the OIDC endpoint. As such, # we need to wrap this in a VCR call. Calling this before @@ -29,164 +25,211 @@ end end - describe '.callback', type: 'unit' do - context 'when credentials are valid' do - it 'returns a valid JWT token', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials' do - # Because JWT tokens have an expiration timeframe, we need to hold - # time constant after caching the request. - travel_to(Time.parse("2022-09-30 17:02:17 +0000")) do - token = client.callback( - code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw', - code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', - nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d' - ) - expect(token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken) - expect(token.raw_attributes['nonce']).to eq('7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d') - expect(token.raw_attributes['preferred_username']).to eq('test.user3@mycompany.com') - expect(token.aud).to eq('0oa3w3xig6rHiu9yT5d7') + shared_examples 'happy path' do |config| + describe '.callback', type: 'unit' do + context 'when credentials are valid' do + it 'returns a valid JWT token', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-valid_oidc_credentials" do + travel_to(Time.parse(config[:auth_time])) do + token = client(config).callback( + 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 end + end - context 'when code verifier does not match' do - it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_callback-invalid_code_verifier' do - travel_to(Time.parse("2022-10-17 17:23:30 +0000")) do - expect do - client.callback( - code: 'GV48_SF4a19ghvBhVbbSG3Lr8BuFl8PhWVPZSbokV2o', - code_verifier: 'bad-code-verifier', - nonce: '3e6bd5235e4692b37ca1f04cb01b6e0cb177aa20dcef19e89f' + shared_examples 'token retrieval failures' do |config| + describe '.callback', type: 'unit' do + context 'when code verifier does not match' do + it 'raises an error', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-invalid_code_verifier" do + travel_to(Time.parse(config[:auth_time])) do + expect do + client(config).callback( + code: config[:code], + code_verifier: 'bad-code-verifier', + nonce: config[:nonce] + ) + end.to raise_error( + Errors::Authentication::AuthnOidc::TokenRetrievalFailed, + "CONJ00133E Access Token retrieval failure: 'PKCE verification failed'" ) - end.to raise_error( - Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - "CONJ00133E Access Token retrieval failure: 'PKCE verification failed'" - ) + end end end - end - context 'when nonce does not match' do - it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials' do - travel_to(Time.parse("2022-09-30 17:02:17 +0000")) do + context 'when code has previously been used' do + it 'raise an exception', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-used_code-valid_oidc_credentials" do expect do - client.callback( - code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw', - code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', - nonce: 'bad-nonce' + client(config).callback( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce] ) end.to raise_error( - Errors::Authentication::AuthnOidc::TokenVerificationFailed, - "CONJ00128E JWT Token validation failed: 'Provided nonce does not match the nonce in the JWT'" + Errors::Authentication::AuthnOidc::TokenRetrievalFailed, + "CONJ00133E Access Token retrieval failure: 'Authorization code is invalid or has expired'" ) end end - end - - context 'when JWT has expired' do - it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials' do - travel_to(Time.parse("2022-10-01 17:02:17 +0000")) do + + 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 - client.callback( - code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw', - code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', - nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d' + client(config).callback( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce] ) end.to raise_error( - Errors::Authentication::AuthnOidc::TokenVerificationFailed, - "CONJ00128E JWT Token validation failed: 'JWT has expired'" + Errors::Authentication::AuthnOidc::TokenRetrievalFailed, + "CONJ00133E Access Token retrieval failure: 'Authorization code is invalid or has expired'" ) end end end + end - context 'when code has previously been used' do - it 'raise an exception', vcr: 'authenticators/authn-oidc/v2/client_callback-used_code-valid_oidc_credentials' do - expect do - client.callback( - code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw', - code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', - nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d' - ) - end.to raise_error( - Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - "CONJ00133E Access Token retrieval failure: 'Authorization code is invalid or has expired'" - ) + shared_examples 'token validation failures' do |config| + describe '.callback', type: 'unit' do + context 'when nonce does not match' do + it 'raises an error', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-valid_oidc_credentials" do + travel_to(Time.parse(config[:auth_time])) do + expect do + client(config).callback( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: 'bad-nonce' + ) + end.to raise_error( + Errors::Authentication::AuthnOidc::TokenVerificationFailed, + "CONJ00128E JWT Token validation failed: 'Provided nonce does not match the nonce in the JWT'" + ) + end + end end - end - context 'when code has expired', vcr: 'authenticators/authn-oidc/v2/client_callback-expired_code-valid_oidc_credentials' do - it 'raise an exception' do - expect do - client.callback( - code: 'SNSPeiQJ0-D6nUHTg-Ht9ZoDxIaaWBB80pnYuXY2VxU', - code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d', - nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d' - ) - end.to raise_error( - Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - "CONJ00133E Access Token retrieval failure: 'Authorization code is invalid or has expired'" - ) + context 'when JWT has expired' do + it 'raises an error', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_callback-valid_oidc_credentials" do + travel_to(Time.parse(config[:auth_time]) + 86400) do + expect do + client(config).callback( + code: config[:code], + code_verifier: config[:code_verifier], + nonce: config[:nonce] + ) + end.to raise_error( + Errors::Authentication::AuthnOidc::TokenVerificationFailed, + "CONJ00128E JWT Token validation failed: 'JWT has expired'" + ) + end + end end end end - describe '.oidc_client', type: 'unit' do - context 'when credentials are valid' do - it 'returns a valid oidc client', vcr: 'authenticators/authn-oidc/v2/client_initialization' do - oidc_client = client.oidc_client - - expect(oidc_client).to be_a_kind_of(OpenIDConnect::Client) - expect(oidc_client.identifier).to eq('0oa3w3xig6rHiu9yT5d7') - expect(oidc_client.secret).to eq('e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj') - expect(oidc_client.redirect_uri).to eq('http://localhost:3000/authn-oidc/okta-2/cucumber/authenticate') - expect(oidc_client.scheme).to eq('https') - expect(oidc_client.host).to eq('dev-92899796.okta.com') - expect(oidc_client.port).to eq(443) - expect(oidc_client.authorization_endpoint).to eq('/oauth2/default/v1/authorize') - expect(oidc_client.token_endpoint).to eq('/oauth2/default/v1/token') - expect(oidc_client.userinfo_endpoint).to eq('/oauth2/default/v1/userinfo') + shared_examples 'client setup' do |config| + describe '.oidc_client', type: 'unit' do + context 'when credentials are valid' do + it 'returns a valid oidc client', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/client_initialization" do + oidc_client = client(config).oidc_client + + expect(oidc_client).to be_a_kind_of(OpenIDConnect::Client) + expect(oidc_client.identifier).to eq(config[:client_id]) + expect(oidc_client.secret).to eq(config[:client_secret]) + expect(oidc_client.redirect_uri).to eq("http://localhost:3000/authn-oidc/#{config[:service_id]}/cucumber/authenticate") + expect(oidc_client.scheme).to eq('https') + expect(oidc_client.host).to eq(config[:host]) + expect(oidc_client.port).to eq(443) + expect(oidc_client.authorization_endpoint).to eq(config[:expected_authz]) + expect(oidc_client.token_endpoint).to eq(config[:expected_token]) + expect(oidc_client.userinfo_endpoint).to eq(config[:expected_userinfo]) + end end end - end - describe '.discovery_information', type: 'unit', vcr: 'authenticators/authn-oidc/v2/discovery_endpoint-valid_oidc_credentials' do - context 'when credentials are valid' do - it 'endpoint returns valid data' do - discovery_information = client.discovery_information(invalidate: true) + describe '.discovery_information', type: 'unit', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/discovery_endpoint-valid_oidc_credentials" do + context 'when credentials are valid' do + it 'endpoint returns valid data' do + discovery_information = client(config).discovery_information(invalidate: true) - expect(discovery_information.authorization_endpoint).to eq( - 'https://dev-92899796.okta.com/oauth2/default/v1/authorize' - ) - expect(discovery_information.token_endpoint).to eq( - 'https://dev-92899796.okta.com/oauth2/default/v1/token' - ) - expect(discovery_information.userinfo_endpoint).to eq( - 'https://dev-92899796.okta.com/oauth2/default/v1/userinfo' - ) - expect(discovery_information.jwks_uri).to eq( - 'https://dev-92899796.okta.com/oauth2/default/v1/keys' - ) + expect(discovery_information.authorization_endpoint).to eq("https://#{config[:host]}#{config[:expected_authz]}") + expect(discovery_information.token_endpoint).to eq("https://#{config[:host]}#{config[:expected_token]}") + expect(discovery_information.userinfo_endpoint).to eq("https://#{config[:host]}#{config[:expected_userinfo]}") + expect(discovery_information.jwks_uri).to eq("https://#{config[:host]}#{config[:expected_keys]}") + end end - end - context 'when provider URI is invalid', vcr: 'authenticators/authn-oidc/v2/discovery_endpoint-invalid_oidc_provider' do - it 'returns an timeout error' do - client = Authentication::AuthnOidc::V2::Client.new( - authenticator: Authentication::AuthnOidc::V2::DataObjects::Authenticator.new( - provider_uri: 'https://foo.bar1234321.com', - redirect_uri: 'http://localhost:3000/authn-oidc/okta-2/cucumber/authenticate', - client_id: '0oa3w3xig6rHiu9yT5d7', - client_secret: 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj', - claim_mapping: 'foo', - account: 'bar', - service_id: 'baz' + context 'when provider URI is invalid', vcr: "authenticators/authn-oidc/v2/#{config[:service_id]}/discovery_endpoint-invalid_oidc_provider" do + it 'returns an timeout error' do + client = Authentication::AuthnOidc::V2::Client.new( + authenticator: Authentication::AuthnOidc::V2::DataObjects::Authenticator.new( + provider_uri: 'https://foo.bar1234321.com', + redirect_uri: "http://localhost:3000/authn-oidc/#{config[:service_id]}/cucumber/authenticate", + client_id: config[:client_id], + client_secret: config[:client_secret], + claim_mapping: config[:claim_mapping], + account: 'bar', + service_id: 'baz' + ) ) - ) - expect{client.discovery_information(invalidate: true)}.to raise_error( - Errors::Authentication::OAuth::ProviderDiscoveryFailed - ) + expect{client.discovery_information(invalidate: true)}.to raise_error( + Errors::Authentication::OAuth::ProviderDiscoveryFailed + ) + 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 diff --git a/spec/app/domain/authentication/authn_iam/authenticator_spec.rb b/spec/app/domain/authentication/authn_iam/authenticator_spec.rb index 97d886b05a..0eadf4f528 100644 --- a/spec/app/domain/authentication/authn_iam/authenticator_spec.rb +++ b/spec/app/domain/authentication/authn_iam/authenticator_spec.rb @@ -3,103 +3,178 @@ require 'spec_helper' RSpec.describe(Authentication::AuthnIam::Authenticator) do - def expired_aws_headers - "{\"host\":\"sts.amazonaws.com\",\"x-amz-date\":\"20180620T025910Z\","\ - "\"x-amz-security-token\":\"FQoDYXdzEPv//////////wEaDHwvkDqh5pHmZNe5hSK3AzevmnHjzweG6m1in"\ - "/CQ8NB7PCY0nTtWsCXLU5FsHmOoXs6KgVOu8ucghebak4b/iaDCpSprH3GPjLcNatywkUEQqX8rQKy2DoKMy7ZMHNT1ivhEn "\ - "vE3HR0GPkkGGWYhLTTrQDdI5fBcb3yJ /TyrcmUuBTKXwQJmvcnDe505SPpuSZm7tdrDX5SpItMngqGcrRhCjuprpk5nPVwSQ"\ - "q6usp7hJYPmu/6u9eVP3rQ TFPldhRvRxu5rcssURdrIwbjMugZQff/8XERxyxPrTkJQekcqMvvV6gexDZcBOS1JtIsKfJEXU"\ - "mK3kwV4liQsUevxyanWMc4jT0tiBkDj2 nvXUFt6dejppdTTRdEtBXg5xZUrGDCQDUU9eBgydoTLGav9rWiM7bWtpP4A1m0E9"\ - "LoX47FScSDkqk0Hy6Dr9jzhb4HOodlgaldTs8BNlgN9xXgACdacdPqnhaYLCgAWsaUZPKuZmdyH96F59rcrVscf456ivXTrXp"\ - "t6pL1ZQyRCc04hkovErvv1L2CwEaGAc k0bvbq0pbzTftTh7 9xY3pFxbL AALoR0t2/CfhyomvoG72Cl/nvAo7 "\ - "m2QU\",\"x-amz-content-sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b"\ - "855\",\"authorization\":\"AWS4-HMAC-SHA256 Credential=ASIAJJTVXJS5KDKXKNPQ/20180620/us-east-1/sts/aw"\ - "s4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=230fa"\ - "38a232969747b77e82f6c845f63941ebde89eb2cc20ed1c6f2dbabc92b6\"}" - end - - def valid_response - double('HTTPResponse', - code: 200, - body: %( - - - arn:aws:sts::011915987442:assumed-role/MyApp/i-0a5702a5a078e1a00 - AROAIYXQMEFIAVEOFMW5Y:i-0a5702a5a078e1a00 - 011915987442 - - - f555066a-7417-11e8-8ded-8daed431985e - - + let(:authenticator) { Authentication::AuthnIam::Authenticator.new(env:[]) } + let(:conjur_role) { 'host/foo/188945769008/conjur-role' } + + # 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"}' } + + # 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"}' } + let(:different_signing_request_uri) { '{"host":"sts.amazonaws.com","x-amz-date":"20230518T153130Z","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=2f0162e0124dfeb105bc8a318efb93272797e2762a4fe93010437594a0d0244d"}' } + 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"}' } + + describe '.valid?' do + context 'headers are valid' do + context 'when using the global STS endpoint' do + context 'with request signed by `us-east-1`' do + let(:payload) do + double('AuthenticationParameters', credentials: valid_global_headers, username: conjur_role) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-global-headers' 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) + end + it 'fails', vcr: 'authenticators/authn-iam/global-sts-non-global-signer' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + 'CONJ00018E Invalid or expired AWS headers: Credential should be scoped to a valid region.' + ) + end + end + end + context 'when using the regional STS endpoint' do + context 'when regional endpoint request was signed for that region' do + let(:payload) do + double('AuthenticationParameters', credentials: valid_regional_headers, username: conjur_role) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-regional-headers' 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) + end + it 'fails', vcr: 'authenticators/authn-iam/regional-headers-signed-for-another-region' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + 'CONJ00018E Invalid or expired AWS headers: Credential should be scoped to a valid region.' + ) + end + end + end + end + context 'when headers are invalid' do + context 'when headers have expired' do + let(:payload) do + double('AuthenticationParameters', credentials: expired_headers, username: conjur_role) + end + it 'fails with exception', vcr: 'authenticators/authn-iam/expired-headers' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + 'CONJ00018E Invalid or expired AWS headers: Signature expired: 20230518T152442Z is now earlier than 20230518T153438Z (20230518T154938Z - 15 min.)' ) - ) - end - - def invalid_response - double('HTTPResponse', - code: 404, - body: "Error" - ) - end - - def valid_login - "host/myapp/011915987442/MyApp" - end - - def invalid_login - "host/myapp/InvalidAccount/InvalidRole" - end - - let (:authenticator_instance) do - Authentication::AuthnIam::Authenticator.new(env:[]) - end - - it "valid? with expired AWS headers", vcr: 'authenticators/authn-iam/verify-expired-headers' do - subject = authenticator_instance - parameters = double('AuthenticationParameters', credentials: expired_aws_headers) - expect{subject.valid?(parameters)}.to( - raise_error(Errors::Authentication::AuthnIam::InvalidAWSHeaders) - ) - end - - it "validates identity_hash with valid response" do - subject = authenticator_instance - expect { subject.identity_hash(valid_response) }.to_not raise_error - - expect(subject.identity_hash(valid_response)).to have_key("GetCallerIdentityResponse") - - expected = { - "GetCallerIdentityResponse" => a_hash_including( - "GetCallerIdentityResult" => a_hash_including( - "Arn" => "arn:aws:sts::011915987442:assumed-role/MyApp/i-0a5702a5a078e1a00", - "UserId" => anything, - "Account" => anything - ) + end + end + context 'for the signing request' do + context 'when the verb is not GET' do + let(:payload) do + double('AuthenticationParameters', credentials: different_signing_request_verb, username: conjur_role) + end + it 'fails with exception', vcr: 'authenticators/authn-iam/invalid-signing-request-verb' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + 'CONJ00018E Invalid or expired AWS headers: 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.' + ) + end + end + context 'when the signed URI is different' do + let(:payload) do + double('AuthenticationParameters', credentials: different_signing_request_uri, username: conjur_role) + end + it 'fails', vcr: 'authenticators/authn-iam/different-signing-request-url' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::InvalidAWSHeaders, + 'CONJ00018E Invalid or expired AWS headers: 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.' + ) + end + end + end + end + context 'when an http exception occurs' do + let(:authenticator) do + Authentication::AuthnIam::Authenticator.new( + env: [], + client: class_double(Net::HTTP).tap do |double| + allow(double).to receive(:get_response).and_raise(Timeout::Error) + end ) - } - - expect(subject.identity_hash(valid_response)).to include(expected) - expect(subject.identity_hash(valid_response)) - end - - it "validates identity_hash with invalid response" do - subject = authenticator_instance - expect(subject.identity_hash(invalid_response)).to eq(false) - end - - it "matches valid login to AWS IAM role (based on AWS response)" do - subject = authenticator_instance - identity_hash = subject.identity_hash(valid_response) - - expect(subject.iam_role_matches?(valid_login, identity_hash)).to eq(true) - expect(subject.iam_role_matches?(invalid_login, identity_hash)).to eq(false) + end + let(:payload) do + double('AuthenticationParameters', credentials: valid_global_headers, username: conjur_role) + end + it 'fails' do + expect { authenticator.valid?(payload) } + .to raise_error( + Errors::Authentication::AuthnIam::VerificationError, + 'CONJ00063E Verification of IAM identity failed with exception: Timeout::Error' + ) + end + end end - - it "fails invalid login with AWS IAM role (based on AWS response)" do - subject = authenticator_instance - identity_hash = subject.identity_hash(valid_response) - - expect(subject.iam_role_matches?(invalid_login, identity_hash)).to eq(false) + context 'for different roles' do + context 'when login is a User' do + let(:payload) do + double('AuthenticationParameters', username: 'foo/bar/baz/188945769008/conjur-role', credentials: valid_global_headers) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-global-headers' do + expect(authenticator.valid?(payload)).to be(true) + end + context 'when login is in the root policy' do + let(:payload) do + double('AuthenticationParameters', username: '188945769008/conjur-role', credentials: valid_global_headers) + end + it 'fails', vcr: 'authenticators/authn-iam/valid-global-headers' do + expect(authenticator.valid?(payload)).to be(false) + end + end + end + context 'when login is a host' do + context 'when role has multiple policy nestings' do + let(:payload) do + double('AuthenticationParameters', username: 'host/foo/bar/baz/188945769008/conjur-role', credentials: valid_global_headers) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-global-headers' do + expect(authenticator.valid?(payload)).to be(true) + end + end + context 'when account-id is missing from username' do + let(:payload) do + double('AuthenticationParameters', username: 'host/foo/bar/baz/conjur-role', credentials: valid_global_headers) + end + it 'fails', vcr: 'authenticators/authn-iam/valid-global-headers' do + expect(authenticator.valid?(payload)).to be(false) + end + end + context 'when login does not include an AWS resource id' do + let(:payload) do + double('AuthenticationParameters', username: 'host/foo/188945769008', credentials: valid_global_headers) + end + it 'fails', vcr: 'authenticators/authn-iam/valid-global-headers' do + expect(authenticator.valid?(payload)).to be(false) + end + end + context 'when login is in the root policy' do + let(:payload) do + double('AuthenticationParameters', username: 'host/188945769008/conjur-role', credentials: valid_global_headers) + end + it 'succeeds', vcr: 'authenticators/authn-iam/valid-global-headers' do + expect(authenticator.valid?(payload)).to be(true) + end + end + end end end diff --git a/spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb b/spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb new file mode 100644 index 0000000000..46781e712d --- /dev/null +++ b/spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'openssl' +require 'stringio' + +require 'domain/authentication/authn_k8s/proxied_tcp_socket' + +describe Authentication::AuthnK8s::ProxiedTcpSocket do + describe '#new' do + let(:proxy_uri) { URI.parse("http://proxy.local:8080") } + let(:destination_host) { 'destination.example.com' } + let(:destination_port) { 443 } + let(:timeout) { 0.1 } + + let(:proxy_response) do + "200 connection established\r\n\r\n" + end + + let(:proxy_data) { StringIO.new } + + let(:tcp_socket_double) do + instance_double(TCPSocket).tap do |tcp_socket_double| + allow(tcp_socket_double).to receive(:write) do |value| + proxy_data << value + end + + allow(tcp_socket_double).to receive(:read).and_return(proxy_response) + end + end + + subject do + described_class.new( + proxy_uri: proxy_uri, + destination_host: destination_host, + destination_port: destination_port, + timeout: timeout + ) + end + + before do + allow(TCPSocket).to receive(:new).and_return(tcp_socket_double) + + # IO#select requires an actual IO device, so we must mock the call and return + # whether it is ready or not + allow(IO).to receive(:select).and_return([[tcp_socket_double], nil, nil]) + end + + it "establishes a TCP connection through the configured proxy" do + expect(TCPSocket).to receive(:new).with( + proxy_uri.host, + proxy_uri.port, + connect_timeout: timeout + ) + subject + end + + it "sends the connect messages" do + subject + expect(proxy_data.string).to include( + "CONNECT destination.example.com:443 HTTP/1.1\r\n" \ + "Host: destination.example.com\r\n\r\n" + ) + end + + context "when the proxy connection fails" do + let(:proxy_response) do + "500 internal server error\r\n\r\n" + end + + it "raises an exception" do + expect { subject }.to raise_error( + RuntimeError, + "Proxy ('proxy.local:8080') returned an invalid response: " \ + "'500 internal server error'" + ) + end + end + + context "when proxy authorization is set" do + let(:proxy_uri) { URI.parse("http://user:pass@proxy.local:8080") } + + it "include the proxy authorization header" do + subject + + auth_value = Base64.strict_encode64( + "#{proxy_uri.user}:#{proxy_uri.password}" + ) + expect(proxy_data.string).to include( + "Proxy-Authorization: Basic #{auth_value}" + ) + end + end + + context "when only proxy username is set" do + let(:proxy_uri) { URI.parse("http://user@proxy.local:8080") } + + it "doesn't include the proxy authorization header" do + subject + + expect(proxy_data.string).not_to include("Proxy-Authorization:") + end + end + end +end \ No newline at end of file diff --git a/spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb b/spec/app/domain/authentication/authn_k8s/web_socket_client_spec.rb similarity index 98% rename from spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb rename to spec/app/domain/authentication/authn_k8s/web_socket_client_spec.rb index 39e28fd9a1..0dc1700d7f 100644 --- a/spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb +++ b/spec/app/domain/authentication/authn_k8s/web_socket_client_spec.rb @@ -3,6 +3,7 @@ # Run this file by calling: # bundle exec rspec spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb --format documentation +require 'spec_helper' require 'openssl' require 'domain/authentication/authn_k8s/web_socket_client' @@ -21,20 +22,20 @@ end describe 'Authentication::AuthnK8s::WebSocketClient' do - context 'server running' do + context 'server running' do context 'without TLS' do before(:example) do @test_server = WebSocketTestServer.new @client = nil end - + after(:example) do @test_server.close @client && @client.close @test_server = nil @client = nil end - + it 'has good handshake with no options' do @test_server.add_websocket @test_server.run @@ -43,7 +44,7 @@ expect(@test_server.good_handshake?).to be_truthy expect(@client.open?).to be_truthy end - + it 'has good handshake with options' do @test_server.add_websocket @test_server.run @@ -53,7 +54,7 @@ expect(@test_server.good_handshake?).to be_truthy expect(@client.open?).to be_truthy end - + it 'is not open for communication without a good handshake' do @test_server.add_bad_websocket @test_server.run @@ -80,13 +81,13 @@ it 'fails cert verification without options' do @test_server.add_websocket @test_server.run - expect { + expect { @client = Authentication::AuthnK8s::WebSocketClient.connect("wss://localhost:#{@test_server.port}") }.to raise_error(OpenSSL::SSL::SSLError, nil) { |error| - expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") + expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") } end - + it 'passes all TLS verifications with good options' do @test_server.add_websocket @test_server.run @@ -152,10 +153,10 @@ it 'fails cert verification without options' do @test_server.add_websocket @test_server.run - expect { + expect { @client = Authentication::AuthnK8s::WebSocketClient.connect("wss://localhost:#{@test_server.port}") }.to raise_error(OpenSSL::SSL::SSLError, nil) { |error| - expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") + expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") } 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..9ea7e76fae --- /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({ message: '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({ message: '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({ message: '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", { '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", { '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 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/database\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/database\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/database\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({ message: "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/database\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({ message: "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/database\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({ message: "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/database\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({ message: "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/database\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/factories/shared.rb b/spec/app/domain/factories/shared.rb new file mode 100644 index 0000000000..58168d7ed2 --- /dev/null +++ b/spec/app/domain/factories/shared.rb @@ -0,0 +1,72 @@ +require 'json_schemer' + +RSpec.shared_context('factory schema', shared_context: :metadata) do + context 'factory schema' do + let(:data) { JSON.parse(Base64.decode64(subject.data)) } + let(:schema) { data['schema'] } + + it 'includes policy, policy_branch, and schema' do + data_keys = data.keys.sort + expect(data_keys).to eq(%w[policy policy_branch schema]) + end + + context 'JSON schema is valid' do + it 'successfully validates schema' do + expect { JSONSchemer.schema(schema) }.not_to raise_error + end + end + + context 'schema defines all available properties' do + it 'includes the property: "id"' do + expect(schema['properties'].keys.sort).to eq(available_properties) + end + end + + context 'schema defines all required properties' do + it 'requires "id"' do + expect(schema['required'].sort).to eq(required_properties) + end + end + end +end + +RSpec.shared_context('factory schema with variables', shared_context: :metadata) do + context 'factory schema with variables' do + let(:data) { JSON.parse(Base64.decode64(subject.data)) } + let(:schema) { data['schema']['properties']['variables'] } + + context 'schema defines all available variables' do + it 'includes the available variable fields' do + expect(schema['properties'].keys.sort).to eq(available_variables) + end + end + + context 'schema defines all required variables' do + it 'defines the required variables' do + expect(schema['required'].sort).to eq(required_variables) + end + end + end +end + +RSpec.shared_context('policy template', shared_context: :metadata) do + context 'policy template' do + let(:data) { JSON.parse(Base64.decode64(subject.data)) } + let(:policy_template) { Base64.decode64(data['policy']) } + + context 'it includes all properties as variables' do + it 'includes relevant variables' do + (available_properties + required_properties - ['variables']).uniq.each do |property| + expect(policy_template).to include("<%= #{property} %>") + end + end + end + end +end + + +RSpec.configure do |rspec| + rspec.include_context('factory schema', include_shared: true) + rspec.include_context('factory schema with variables', include_shared: true) + rspec.include_context('policy template', include_shared: true) +end diff --git a/spec/app/domain/factories/templates/authenticators/authn_oidc_spec.rb b/spec/app/domain/factories/templates/authenticators/authn_oidc_spec.rb new file mode 100644 index 0000000000..253255aeba --- /dev/null +++ b/spec/app/domain/factories/templates/authenticators/authn_oidc_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require './spec/app/domain/factory/shared' + +RSpec.describe(Factories::Templates::Authenticators::AuthnOidc) do + subject { Factories::Templates::Authenticators::AuthnOidc } + let(:available_properties) { %w[id variables] } + let(:required_properties) { available_properties } + + let(:available_variables) { %w[claim-mapping client-id client-secret provider-uri redirect-uri] } + let(:required_variables) { %w[claim-mapping client-id client-secret provider-uri] } + + describe('#data') do + # Tests to ensure the integrity of the interface + include_context 'factory schema' + include_context 'factory schema with variables' + end + + describe('#policy_template') do + # Tests to ensure the integrity of the interface + include_context 'policy template' + # include_context 'factory schema with variables' + end +end diff --git a/spec/app/domain/responses_spec.rb b/spec/app/domain/responses_spec.rb new file mode 100644 index 0000000000..06532a2f00 --- /dev/null +++ b/spec/app/domain/responses_spec.rb @@ -0,0 +1,124 @@ +# 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 + 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({ message: '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 + 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 hash with the key 'message'" do + expect(failure.message).to eq({ message: '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_h' do + let(:message) { 'baz' } + context 'when default status is used' do + it 'returns an error and code' do + expect(failure.to_h).to eq({ code: 403, error: { message: 'baz' } }) + end + end + context 'when defined status is used' do + let(:initialize_arguments) { { level: :debug, status: :bad_request } } + it 'returns an error and code' do + expect(failure.to_h).to eq({ code: 400, error: { message: '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/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/fixtures/vcr_cassettes/authenticators/authn-iam/different-signing-request-url.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/different-signing-request-url.yml new file mode 100644 index 0000000000..975a4f8311 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/different-signing-request-url.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + X-Amz-Date: + - 20230518T153130Z + 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=2f0162e0124dfeb105bc8a318efb93272797e2762a4fe93010437594a0d0244d + 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: + - 616d2ad4-7fc4-4188-acfa-ecc9cbfbc366 + Content-Type: + - text/xml + Content-Length: + - '431' + Date: + - Thu, 18 May 2023 15:35:06 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. + + 616d2ad4-7fc4-4188-acfa-ecc9cbfbc366 + + recorded_at: Thu, 18 May 2023 15:35:06 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/expired-headers.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/expired-headers.yml new file mode 100644 index 0000000000..f20e5868b9 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/expired-headers.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.eu-central-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + 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 + 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: + - 7b86b01a-232e-417b-b8dd-f5f99253957e + Content-Type: + - text/xml + Content-Length: + - '354' + Date: + - Thu, 18 May 2023 15:49:37 GMT + body: + encoding: UTF-8 + string: | + + + Sender + SignatureDoesNotMatch + Signature expired: 20230518T152442Z is now earlier than 20230518T153438Z (20230518T154938Z - 15 min.) + + 7b86b01a-232e-417b-b8dd-f5f99253957e + + recorded_at: Thu, 18 May 2023 15:49:38 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/global-sts-non-global-signer.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/global-sts-non-global-signer.yml new file mode 100644 index 0000000000..a03c5e55bb --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/global-sts-non-global-signer.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + 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 + 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: + - dfaae187-a48e-4432-a1d2-f2267aed9ded + Content-Type: + - text/xml + Content-Length: + - '300' + Date: + - Thu, 18 May 2023 16:00:55 GMT + body: + encoding: UTF-8 + string: | + + + Sender + SignatureDoesNotMatch + Credential should be scoped to a valid region. + + dfaae187-a48e-4432-a1d2-f2267aed9ded + + recorded_at: Thu, 18 May 2023 16:00:55 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/invalid-signing-request-verb.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/invalid-signing-request-verb.yml new file mode 100644 index 0000000000..92e6d84c4e --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/invalid-signing-request-verb.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + 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 + 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: + - a3285804-b289-4b2b-8138-d80cbdf6287f + Content-Type: + - text/xml + Content-Length: + - '431' + Date: + - Thu, 18 May 2023 15:35:05 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. + + a3285804-b289-4b2b-8138-d80cbdf6287f + + recorded_at: Thu, 18 May 2023 15:35:05 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/regional-headers-signed-for-another-region.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/regional-headers-signed-for-another-region.yml new file mode 100644 index 0000000000..91d709ffde --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/regional-headers-signed-for-another-region.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.us-west-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + 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 + 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: + - bdf82d4c-a300-4323-aa26-08cba28de47e + Content-Type: + - text/xml + Content-Length: + - '300' + Date: + - Thu, 18 May 2023 16:08:41 GMT + body: + encoding: UTF-8 + string: | + + + Sender + SignatureDoesNotMatch + Credential should be scoped to a valid region. + + bdf82d4c-a300-4323-aa26-08cba28de47e + + recorded_at: Thu, 18 May 2023 16:08:41 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers.yml new file mode 100644 index 0000000000..ba3ce74486 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-global-headers.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + 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 + 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: + - c626dec1-0bb0-42c9-82b5-cc957aeca0a5 + Content-Type: + - text/xml + Content-Length: + - '456' + Date: + - Thu, 18 May 2023 15:28:32 GMT + body: + encoding: UTF-8 + string: | + + + arn:aws:sts::188945769008:assumed-role/conjur-role/i-08241b0e31fe23d20 + AROASX7QLUIYK4AQBODTV:i-08241b0e31fe23d20 + 188945769008 + + + c626dec1-0bb0-42c9-82b5-cc957aeca0a5 + + + recorded_at: Thu, 18 May 2023 15:28:33 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers.yml new file mode 100644 index 0000000000..bf4a4681ae --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/valid-regional-headers.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: get + uri: https://sts.eu-central-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 + body: + encoding: US-ASCII + string: '' + headers: + 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 + 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: + - c025e1ba-c36b-4078-9407-fdd02eaee5aa + Content-Type: + - text/xml + Content-Length: + - '456' + Date: + - Thu, 18 May 2023 15:28:33 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: Thu, 18 May 2023 15:28:33 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/verify-expired-headers.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-iam/verify-expired-headers.yml deleted file mode 100644 index c029eb78d5..0000000000 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-iam/verify-expired-headers.yml +++ /dev/null @@ -1,54 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15 - body: - encoding: US-ASCII - string: '' - headers: - Accept: - - "*/*" - User-Agent: - - rest-client/2.1.0 (linux x86_64) ruby/3.0.5p211 - X-Amz-Date: - - 20180620T025910Z - X-Amz-Security-Token: - - FQoDYXdzEPv//////////wEaDHwvkDqh5pHmZNe5hSK3AzevmnHjzweG6m1in/CQ8NB7PCY0nTtWsCXLU5FsHmOoXs6KgVOu8ucghebak4b/iaDCpSprH3GPjLcNatywkUEQqX8rQKy2DoKMy7ZMHNT1ivhEn - vE3HR0GPkkGGWYhLTTrQDdI5fBcb3yJ /TyrcmUuBTKXwQJmvcnDe505SPpuSZm7tdrDX5SpItMngqGcrRhCjuprpk5nPVwSQq6usp7hJYPmu/6u9eVP3rQ - TFPldhRvRxu5rcssURdrIwbjMugZQff/8XERxyxPrTkJQekcqMvvV6gexDZcBOS1JtIsKfJEXUmK3kwV4liQsUevxyanWMc4jT0tiBkDj2 - nvXUFt6dejppdTTRdEtBXg5xZUrGDCQDUU9eBgydoTLGav9rWiM7bWtpP4A1m0E9LoX47FScSDkqk0Hy6Dr9jzhb4HOodlgaldTs8BNlgN9xXgACdacdPqnhaYLCgAWsaUZPKuZmdyH96F59rcrVscf456ivXTrXpt6pL1ZQyRCc04hkovErvv1L2CwEaGAc - k0bvbq0pbzTftTh7 9xY3pFxbL AALoR0t2/CfhyomvoG72Cl/nvAo7 m2QU - X-Amz-Content-Sha256: - - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - Authorization: - - AWS4-HMAC-SHA256 Credential=ASIAJJTVXJS5KDKXKNPQ/20180620/us-east-1/sts/aws4_request, - SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=230fa38a232969747b77e82f6c845f63941ebde89eb2cc20ed1c6f2dbabc92b6 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - response: - status: - code: 403 - message: Forbidden - headers: - X-Amzn-Requestid: - - 8c409194-abfc-4e7a-914d-e6c63c4b6ebf - Content-Type: - - text/xml - Content-Length: - - '354' - Date: - - Tue, 05 Jul 2022 14:09:51 GMT - body: - encoding: UTF-8 - string: | - - - Sender - SignatureDoesNotMatch - Signature expired: 20180620T025910Z is now earlier than 20220705T135452Z (20220705T140952Z - 15 min.) - - 8c409194-abfc-4e7a-914d-e6c63c4b6ebf - - recorded_at: Tue, 05 Jul 2022 14:09:51 GMT -recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-expired_code-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-expired_code-valid_oidc_credentials.yml new file mode 100644 index 0000000000..e7bbc1eed8 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-expired_code-valid_oidc_credentials.yml @@ -0,0 +1,34 @@ +--- +http_interactions: +- request: + method: post + uri: https://redacted-host/OAuth2/Token/redacted_app + body: + encoding: UTF-8 + string: grant_type=authorization_code&code=puPaKJOr_E25STHsM_-rOo3fgJBz2TKVNsi8GzBvwS41&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fidentity%2Fcucumber%2Fauthenticate&scope=true&code_verifier=9625bb8881c08de323bb17242d6b3552e50aec0e999e15c66a + headers: + User-Agent: + - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + Accept: + - "*/*" + Date: + - Mon, 10 Apr 2023 23:45:36 GMT + Authorization: + - Basic cmVkYWN0ZWQtaWQ6cmVkYWN0ZWQtc2VjcmV0Cg== + Content-Type: + - application/x-www-form-urlencoded + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Mon, 10 Apr 2023 23:45:36 GMT + Content-Length: + - '90' + body: + encoding: UTF-8 + string: '{"error":"invalid_grant","error_description":"supplied code does not + match known request"}' + recorded_at: Mon, 10 Apr 2023 23:45:36 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-invalid_code_verifier.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-invalid_code_verifier.yml new file mode 100644 index 0000000000..a1a032a787 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-invalid_code_verifier.yml @@ -0,0 +1,33 @@ +--- +http_interactions: +- request: + method: post + uri: https://redacted-host/OAuth2/Token/redacted_app + body: + encoding: UTF-8 + string: grant_type=authorization_code&code=puPaKJOr_E25STHsM_-rOo3fgJBz2TKVNsi8GzBvwS41&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fidentity%2Fcucumber%2Fauthenticate&scope=true&code_verifier=bad-code-verifier + headers: + User-Agent: + - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + Accept: + - "*/*" + Date: + - Mon, 17 Oct 2022 17:23:30 GMT + Authorization: + - Basic cmVkYWN0ZWQtaWQ6cmVkYWN0ZWQtc2VjcmV0Cg== + Content-Type: + - application/x-www-form-urlencoded + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Mon, 10 Apr 2023 22:31:31 GMT + Content-Length: + - '66' + body: + encoding: UTF-8 + string: '{"error":"invalid_grant","error_description":"challenge mismatch"}' + recorded_at: Mon, 17 Oct 2022 17:23:30 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-used_code-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-used_code-valid_oidc_credentials.yml new file mode 100644 index 0000000000..588853ef54 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_callback-used_code-valid_oidc_credentials.yml @@ -0,0 +1,34 @@ +--- +http_interactions: +- request: + method: post + uri: https://redacted-host/OAuth2/Token/redacted_app + body: + encoding: UTF-8 + string: grant_type=authorization_code&code=puPaKJOr_E25STHsM_-rOo3fgJBz2TKVNsi8GzBvwS41&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fidentity%2Fcucumber%2Fauthenticate&scope=true&code_verifier=9625bb8881c08de323bb17242d6b3552e50aec0e999e15c66a + headers: + User-Agent: + - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + Accept: + - "*/*" + Date: + - Mon, 10 Apr 2023 21:48:37 GMT + Authorization: + - Basic cmVkYWN0ZWQtaWQ6cmVkYWN0ZWQtc2VjcmV0Cg== + Content-Type: + - application/x-www-form-urlencoded + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Mon, 10 Apr 2023 21:48:37 GMT + Content-Length: + - '90' + body: + encoding: UTF-8 + string: '{"error":"invalid_grant","error_description":"supplied code does not + match known request"}' + recorded_at: Mon, 10 Apr 2023 21:48:37 GMT +recorded_with: VCR 6.1.0 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 new file mode 100644 index 0000000000..dce92df9da --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/client_load.yml @@ -0,0 +1,45 @@ +--- +http_interactions: +- request: + method: get + uri: https://redacted-host/redacted_app/.well-known/openid-configuration + body: + encoding: UTF-8 + string: '' + headers: + User-Agent: + - SWD (1.3.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + Accept: + - "*/*" + Date: + - Mon, 10 Apr 2023 18:13:17 GMT + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 10 Apr 2023 18:13:17 GMT + Content-Length: + - '1347' + body: + encoding: UTF-8 + 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 + \ \"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}" + 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 new file mode 100644 index 0000000000..bd889dabb1 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/identity/discovery_endpoint-valid_oidc_credentials.yml @@ -0,0 +1,45 @@ +--- +http_interactions: +- request: + method: get + uri: https://redacted-host/redacted_app/.well-known/openid-configuration + body: + encoding: UTF-8 + string: '' + headers: + User-Agent: + - SWD (1.3.0) (2.8.3, ruby 3.0.6 (2023-03-30)) + Accept: + - "*/*" + Date: + - Mon, 10 Apr 2023 18:16:56 GMT + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 10 Apr 2023 18:16:56 GMT + Content-Length: + - '1347' + body: + encoding: UTF-8 + 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 + \ \"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}" + recorded_at: Mon, 10 Apr 2023 18:16:56 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-expired_code-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-expired_code-valid_oidc_credentials.yml similarity index 93% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-expired_code-valid_oidc_credentials.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-expired_code-valid_oidc_credentials.yml index 0607f2414c..9bfe2bdc39 100644 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-expired_code-valid_oidc_credentials.yml +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-expired_code-valid_oidc_credentials.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://dev-92899796.okta.com/oauth2/default/v1/token body: encoding: UTF-8 - string: grant_type=authorization_code&code=SNSPeiQJ0-D6nUHTg-Ht9ZoDxIaaWBB80pnYuXY2VxU&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d + string: grant_type=authorization_code&code=-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&scope=true&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d headers: User-Agent: - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.4 (2022-04-12)) diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-invalid_code_verifier.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-invalid_code_verifier.yml similarity index 92% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-invalid_code_verifier.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-invalid_code_verifier.yml index 481657fb01..6902b6c383 100644 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-invalid_code_verifier.yml +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-invalid_code_verifier.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://dev-92899796.okta.com/oauth2/default/v1/token body: encoding: UTF-8 - string: grant_type=authorization_code&code=GV48_SF4a19ghvBhVbbSG3Lr8BuFl8PhWVPZSbokV2o&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&nonce=3e6bd5235e4692b37ca1f04cb01b6e0cb177aa20dcef19e89f&code_verifier=bad-code-verifier + string: grant_type=authorization_code&code=-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&scope=true&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=bad-code-verifier headers: User-Agent: - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.4 (2022-04-12)) diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-used_code-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-used_code-valid_oidc_credentials.yml similarity index 96% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-used_code-valid_oidc_credentials.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-used_code-valid_oidc_credentials.yml index 83374a931a..2a3cc5d4b2 100644 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-used_code-valid_oidc_credentials.yml +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-used_code-valid_oidc_credentials.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://dev-92899796.okta.com/oauth2/default/v1/token body: encoding: UTF-8 - string: grant_type=authorization_code&code=-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d + string: grant_type=authorization_code&code=-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&scope=true&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d headers: User-Agent: - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.4 (2022-04-12)) diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-valid_oidc_credentials.yml similarity index 98% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-valid_oidc_credentials.yml index e837c8dce9..d0df4e7d8c 100644 --- a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials.yml +++ b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_callback-valid_oidc_credentials.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://dev-92899796.okta.com/oauth2/default/v1/token body: encoding: UTF-8 - string: grant_type=authorization_code&code=-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d + string: grant_type=authorization_code&code=-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthn-oidc%2Fokta-2%2Fcucumber%2Fauthenticate&scope=true&nonce=7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d&code_verifier=c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d headers: User-Agent: - Rack::OAuth2 (1.19.0) (2.8.3, ruby 3.0.4 (2022-04-12)) diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_initialization.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_initialization.yml similarity index 100% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_initialization.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_initialization.yml diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_load.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_load.yml similarity index 100% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/client_load.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/client_load.yml diff --git a/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/discovery_endpoint-valid_oidc_credentials.yml b/spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/discovery_endpoint-valid_oidc_credentials.yml similarity index 100% rename from spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/discovery_endpoint-valid_oidc_credentials.yml rename to spec/fixtures/vcr_cassettes/authenticators/authn-oidc/v2/okta-2/discovery_endpoint-valid_oidc_credentials.yml