diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b15457121..4ab0ad200f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.11-cloud] - 2023-10-29 + ## [1.0.10-cloud] - 2023-10-22 ### Added - Telemetry logs for ephemeral secrets @@ -137,14 +139,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Remove auto-release options to allow for a pseudo-fork development on a branch -## [1.20.0] - 2023-07-11 +## [1.20.0] - 2023-08-16 + +### Fixed +- OIDC authenticators support `https_proxy` and `HTTPS_PROXY` environment variables + [cyberark/conjur#2902](https://github.com/cyberark/conjur/pull/2902) +- Support plural syntax for revoke and deny + [cyberark/conjur#2901](https://github.com/cyberark/conjur/pull/2901) ### Added -- Telemetry support - [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) - New flag to `conjurctl server` command called `--no-migrate` which allows for skipping the database migration step when starting the server. [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) +- Telemetry support + [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) +- Introduces support for Policy Factory, which enables resource creation + through a new `factories` API. + [cyberark/conjur#2855](https://github.com/cyberark/conjur/pull/2855/files) ### Changed - The database thread pool max connection size is now based on the number of @@ -152,12 +163,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. mitigates the possibility of a web worker becoming starved while waiting for a connection to become available. [cyberark/conjur#2875](https://github.com/cyberark/conjur/pull/2875) +- Additive policy requests submitted via POST are rejected with a 400 status if + they attempt to update an existing resource. + [cyberark/conjur#2888](https://github.com/cyberark/conjur/pull/2888) ### Fixed - Support Authn-IAM regional requests when host value is missing from signed headers. [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) + +### Security - Support plural syntax for revoke and deny - [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) + [cyberark/conjur#2901](https://github.com/cyberark/conjur/pull/2901) +- Previously, attempting to add and remove a privilege in the same policy load + resulted in only the positive privilege (grant, permit) taking effect. Now we + fail safe and the negative privilege statement (revoke, deny) is the final + outcome + [cyberark/conjur#2907](https://github.com/cyberark/conjur/pull/2907) +- Update puma to 6.3.1 to address CVE-2023-40175. + [cyberark/conjur#2925](https://github.com/cyberark/conjur/pull/2925) ## [1.19.5] - 2023-06-29 diff --git a/Dockerfile b/Dockerfile index 7eb34d0c5e..8bad96e8f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,13 +33,14 @@ COPY Gemfile \ COPY gems/ gems/ -RUN bundle --without test development +RUN bundle --without test development && \ + # Remove private keys brought in by gems in their test data + find / -name openid_connect -type d -exec find {} -name '*.pem' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + find / -name httpclient -type d -exec find {} -name '*.pem' -type f -delete \; COPY . . -# removing CA bundle of httpclient gem -RUN find / -name httpclient -type d -exec find {} -name *.pem -type f -delete \; - RUN ln -sf /opt/conjur-server/bin/conjurctl /usr/local/bin/ ENV RAILS_ENV production diff --git a/Dockerfile.ubi b/Dockerfile.ubi index 53a90115af..ac539680d3 100644 --- a/Dockerfile.ubi +++ b/Dockerfile.ubi @@ -76,7 +76,9 @@ RUN INSTALL_PKGS="gcc \ yum -y clean all --enablerepo='*' && \ # removing CA bundle of httpclient gem find / -name 'httpclient-*' -type d -exec find {} -name '*.pem' -type f -delete \; && \ - find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + # remove the private key in the oidc_connect gem spec directory + find / -name openid_connect -type d -exec find {} -name '*.pem' -type f -delete \; COPY . . diff --git a/Gemfile b/Gemfile index e3a33d72c4..d113bdd311 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem 'http', '~> 4.2.0' gem 'iso8601' gem 'jbuilder', '~> 2.7.0' gem 'nokogiri', '>= 1.8.2' -gem 'puma', '~> 5.6' +gem 'puma', '~> 6' gem 'rack', '~> 2.2' gem 'rails', '~> 6.1', '>= 6.1.4.6' gem 'rake' @@ -62,6 +62,9 @@ gem 'net-ldap' # for AWS rotator gem 'aws-sdk-iam', require: false +# we need this version since any newer introduces braking change that causes issues with safe_yaml: https://github.com/ruby/psych/discussions/571 +gem 'psych', '=3.3.2' + group :production do gem 'rails_12factor' end @@ -72,13 +75,14 @@ gem 'kubeclient' gem 'websocket' # authn-oidc, gcp, azure, jwt -gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements +# gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements +gem 'jwt', '2.7.1' # authn-oidc -gem 'openid_connect' +gem 'openid_connect', '~> 2.0' gem "anyway_config" gem 'i18n', '~> 1.8.11' - +gem 'json_schemer' gem 'prometheus-client' group :development, :test do @@ -90,6 +94,7 @@ group :development, :test do gem 'cucumber', '~> 7.1' gem 'database_cleaner', '~> 1.8' gem 'debase', '~> 0.2.5.beta2' + gem 'debase-ruby_core_source', '~> 3.2.1' gem 'json_spec', '~> 1.1' gem 'faye-websocket' gem 'net-ssh' @@ -103,7 +108,7 @@ group :development, :test do gem 'rspec' gem 'rspec-core' gem 'rspec-rails' - gem 'ruby-debug-ide' + # gem 'ruby-debug-ide' # We use a post-coverage hook to sleep covered processes until we're ready to # collect the coverage reports in CI. Because of this, we don't want bundler diff --git a/Gemfile.lock b/Gemfile.lock index 08eed7f34e..8eb2dca0ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,8 +80,8 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) aes_key_wrap (1.1.0) anyway_config (2.2.3) ruby-next-core (>= 0.14.0) @@ -112,7 +112,7 @@ GEM base32-crockford (0.1.0) base58 (0.2.3) bcrypt (3.1.16) - bindata (2.4.10) + bindata (2.4.15) builder (3.2.4) byebug (11.1.3) childprocess (4.1.0) @@ -124,7 +124,7 @@ GEM coderay (1.1.3) command_class (0.0.2) concurrent-ruby (1.2.2) - conjur-api (5.3.8.pre.194) + conjur-api (5.4.0) activesupport (>= 4.2) addressable (~> 2.0) rest-client @@ -184,7 +184,7 @@ GEM date (3.3.3) debase (0.2.5.beta2) debase-ruby_core_source (>= 0.10.12) - debase-ruby_core_source (0.10.13) + debase-ruby_core_source (3.2.1) deep_merge (1.2.2) diff-lcs (1.4.4) docile (1.4.0) @@ -215,12 +215,20 @@ GEM dry-core (~> 0.5, >= 0.5) dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.0, >= 1.0.2) + ecma-re-validator (0.4.0) + regexp_parser (~> 2.2) erubi (1.12.0) et-orbi (1.2.7) tzinfo event_emitter (0.2.6) eventmachine (1.2.7) excon (0.91.0) + faraday (2.7.10) + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-net_http (3.0.2) faye-websocket (0.11.1) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) @@ -235,6 +243,7 @@ GEM globalid (1.1.0) activesupport (>= 5.0) haikunator (1.1.1) + hana (1.3.7) hashdiff (1.0.1) highline (2.0.3) http (4.2.0) @@ -243,12 +252,11 @@ 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) ffi-compiler (>= 1.0, < 2.0) - httpclient (2.8.3) i18n (1.8.11) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -258,16 +266,23 @@ GEM activesupport (>= 4.2.0) multi_json (>= 1.2) jmespath (1.6.1) - json-jwt (1.13.0) + json-jwt (1.16.3) activesupport (>= 4.2) aes_key_wrap bindata + faraday (~> 2.0) + faraday-follow_redirects + json_schemer (0.2.24) + ecma-re-validator (~> 0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + uri_template (~> 0.7) json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) jsonpath (1.1.0) multi_json - jwt (2.2.2) + jwt (2.7.1) kubeclient (4.9.3) http (>= 3.0, < 5.0) jsonpath (~> 1.0) @@ -311,16 +326,19 @@ GEM racc (~> 1.4) nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) - openid_connect (1.3.0) + openid_connect (2.2.0) activemodel attr_required (>= 1.0.0) - json-jwt (>= 1.5.0) - rack-oauth2 (>= 1.6.1) - swd (>= 1.0.0) + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + net-smtp + rack-oauth2 (~> 2.2) + swd (~> 2.0) tzinfo validate_email validate_url - webfinger (>= 1.0.1) + webfinger (~> 2.0) parallel (1.21.0) parallel_tests (4.2.0) parallel @@ -337,16 +355,18 @@ GEM pry (~> 0.13.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.6) - puma (5.6.4) + psych (3.3.2) + public_suffix (5.0.1) + puma (6.3.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.7.1) rack (2.2.7) - rack-oauth2 (1.19.0) + rack-oauth2 (2.2.0) activesupport attr_required - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-rewrite (1.5.1) @@ -401,6 +421,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) @@ -438,10 +459,9 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) rubocop-checkstyle_formatter (0.4.0) rubocop (>= 0.35.1) - ruby-debug-ide (0.7.3) - rake (>= 0.8.1) ruby-next-core (0.14.0) ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) @@ -473,10 +493,11 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - swd (1.3.0) + swd (2.0.2) activesupport (>= 3) attr_required (>= 0.0.5) - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects sys-uname (1.2.2) ffi (~> 1.1) table_print (1.5.7) @@ -486,18 +507,20 @@ GEM concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.1) + unf_ext (0.0.8.2) unicode-display_width (1.8.0) + uri_template (0.7.0) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) - validate_url (1.0.13) + validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix vcr (6.1.0) - webfinger (1.2.0) + webfinger (2.1.2) activesupport - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects webmock (3.14.0) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -536,6 +559,7 @@ DEPENDENCIES cucumber (~> 7.1) database_cleaner (~> 1.8) debase (~> 0.2.5.beta2) + debase-ruby_core_source (~> 3.2.1) dry-struct dry-types event_emitter @@ -547,22 +571,24 @@ DEPENDENCIES i18n (~> 1.8.11) iso8601 jbuilder (~> 2.7.0) + json_schemer json_spec (~> 1.1) - jwt (= 2.2.2) + jwt (= 2.7.1) kubeclient listen loofah (>= 2.2.3) net-ldap net-ssh nokogiri (>= 1.8.2) - openid_connect + openid_connect (~> 2.0) parallel parallel_tests pg prometheus-client pry-byebug pry-rails - puma (~> 5.6) + psych (= 3.3.2) + puma (~> 6) rack (~> 2.2) rack-rewrite rails (~> 6.1, >= 6.1.4.6) @@ -578,7 +604,6 @@ DEPENDENCIES rspec-rails rubocop (~> 0.58.0) rubocop-checkstyle_formatter - ruby-debug-ide rufus-scheduler sequel sequel-pg_advisory_locking diff --git a/NOTICES.txt b/NOTICES.txt index 9f776f0a92..e30c658d96 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -20,7 +20,7 @@ Section 3: BSD-3-Clause >>> https://rubygems.org/gems/base32-crockford/versions/0.1.0 >>> https://rubygems.org/gems/ffi/versions/1.15.4 ->>> https://rubygems.org/gems/puma/versions/5.6.4 +>>> https://rubygems.org/gems/puma/versions/6.3.1 Section 4: MIT @@ -37,13 +37,13 @@ Section 4: MIT >>> https://rubygems.org/gems/http/versions/4.2.0 >>> https://rubygems.org/gems/iso8601/versions/0.13.0 >>> https://rubygems.org/gems/jbuilder/versions/2.7.0 ->>> https://rubygems.org/gems/jwt/versions/2.2.2 +>>> https://rubygems.org/gems/jwt/versions/2.7.1 >>> https://rubygems.org/gems/kubeclient/versions/4.9.3 >>> https://rubygems.org/gems/listen/versions/3.7.0 >>> https://rubygems.org/gems/loofah/versions/2.20.0 >>> https://rubygems.org/gems/net-ldap/versions/0.17.0 >>> https://rubygems.org/gems/nokogiri/versions/1.14.3 ->>> https://rubygems.org/gems/openid_connect/versions/1.3.0 +>>> https://rubygems.org/gems/openid_connect/versions/2.2.0 >>> https://rubygems.org/gems/rack-rewrite/versions/1.5.1 >>> https://rubygems.org/gems/rails/versions/6.1.7.3 >>> https://rubygems.org/gems/rake/versions/13.0.6 @@ -214,7 +214,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ->>> https://rubygems.org/gems/puma/versions/5.6.4 +>>> https://rubygems.org/gems/puma/versions/6.3.1 Some code copyright (c) 2005, Zed Shaw Copyright (c) 2011, Evan Phoenix @@ -546,7 +546,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/jwt/versions/2.2.2 +>>> https://rubygems.org/gems/jwt/versions/2.7.1 Copyright (c) 2011 Jeff Lindsay @@ -680,7 +680,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/openid_connect/versions/1.3.0 +>>> https://rubygems.org/gems/openid_connect/versions/2.2.0 Copyright (c) 2011 nov matake diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fababebd8a..67ab27d519 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -60,6 +60,7 @@ class UnprocessableEntity < RuntimeError rescue_from Sequel::ForeignKeyConstraintViolation, with: :foreign_key_constraint_violation rescue_from Conjur::PolicyParser::Invalid, with: :policy_invalid rescue_from Exceptions::InvalidPolicyObject, with: :policy_invalid + rescue_from Exceptions::DisallowedPolicyOperation, with: :disallowed_policy_operation rescue_from ArgumentError, with: :argument_error rescue_from ActionController::ParameterMissing, with: :argument_error rescue_from UnprocessableEntity, with: :unprocessable_entity @@ -194,6 +195,17 @@ def policy_invalid e render(json: { error: error }, status: :unprocessable_entity) end + def disallowed_policy_operation e + logger.debug("#{e}\n#{e.backtrace.join("\n")}") + + render(json: { + error: { + code: "disallowed_policy_operation", + message: e.message + } + }, status: :unprocessable_entity) + end + def argument_error e logger.debug("#{e}\n#{e.backtrace.join("\n")}") diff --git a/app/controllers/policy_factories_controller.rb b/app/controllers/policy_factories_controller.rb new file mode 100644 index 0000000000..0e73566d4d --- /dev/null +++ b/app/controllers/policy_factories_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require './app/domain/responses' + +class PolicyFactoriesController < RestController + include AuthorizeResource + + before_action :current_user + + def create + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(%i[account kind version id]) + ).bind do |factory| + Factories::CreateFromPolicyFactory.new.call( + account: params[:account], + factory_template: factory, + request_body: request.body.read, + authorization: request.headers["Authorization"] + ) + end + + render_response(response) do + render(json: response.result) + end + end + + def show + allowed_params = %i[account kind version id] + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(allowed_params) + ) + + render_response(response) do + presenter = Presenter::PolicyFactories::Show.new(factory: response.result) + render(json: presenter.present) + end + end + + def index + response = DB::Repository::PolicyFactoryRepository.new.find_all( + role: current_user, + account: params[:account] + ) + render_response(response) do + presenter = Presenter::PolicyFactories::Index.new(factories: response.result) + render(json: presenter.present) + end + end + + private + + def render_response(response, &block) + if response.success? + block.call + else + presenter = Presenter::PolicyFactories::Error.new(response: response) + render( + json: presenter.present, + status: response.status + ) + end + end + + def relevant_params(allowed_params) + params.permit(*allowed_params).slice(*allowed_params).to_h.symbolize_keys + end +end diff --git a/app/db/repository/policy_factory_repository.rb b/app/db/repository/policy_factory_repository.rb new file mode 100644 index 0000000000..61bb003a22 --- /dev/null +++ b/app/db/repository/policy_factory_repository.rb @@ -0,0 +1,131 @@ +require 'base64' +require 'json' + +require './app/domain/responses' + +module DB + module Repository + module DataObjects + PolicyFactory = Struct.new( + :name, + :classification, + :version, + :policy, + :policy_branch, + :schema, + :description, + keyword_init: true + ) + end + + class PolicyFactoryRepository + def initialize( + data_object: DataObjects::PolicyFactory, + resource: ::Resource, + logger: Rails.logger + ) + @resource = resource + @data_object = data_object + @logger = logger + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def find_all(account:, role:) + factories = @resource.visible_to(role).where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/%" + ) + ).all + .select { |factory| role.allowed_to?(:execute, factory) } + .group_by do |item| + # form is: 'conjur/factories/core/v1/groups' + _, _, classification, _, factory = item.resource_id.split('/') + [classification, factory].join('/') + end + .map do |_, versions| + versions.max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + .map do |factory| + response = secret_to_data_object(factory) + response.result if response.success? + end + .compact + + if factories.empty? + return @failure.new( + 'Role does not have permission to use Factories', + status: :forbidden + ) + end + + @success.new(factories) + end + + def find(kind:, id:, account:, role:, version: nil) + factory = if version.present? + @resource["#{account}:variable:conjur/factories/#{kind}/#{version}/#{id}"] + else + @resource.where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/#{kind}/%" + ) + ).all + .select { |i| i.resource_id.split('/').last == id } + .max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + + resource_id = "#{kind}/#{version || 'v1'}/#{id}" + + if factory.blank? + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory does not exist' }, + status: :not_found + ) + elsif !role.allowed_to?(:execute, factory) + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory is not available' }, + status: :forbidden + ) + else + secret_to_data_object(factory) + end + end + + private + + def factory_version(factory_id) + version_match = factory_id.match(%r{/v(\d+)/[\w-]+}) + return 0 if version_match.nil? + + version_match[1].to_i + end + + def secret_to_data_object(variable) + _, _, classification, version, id = variable.resource_id.split('/') + factory = variable.secret&.value + if factory + decoded_factory = JSON.parse(Base64.decode64(factory)) + @success.new( + @data_object.new( + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'], + schema: decoded_factory['schema'], + version: version, + name: id, + classification: classification, + description: decoded_factory['schema']&.dig('description').to_s + ) + ) + else + @failure.new( + { resource: "#{classification}/#{version}/#{id}", message: 'Requested Policy Factory is not available' }, + status: :bad_request + ) + end + end + end + end +end diff --git a/app/domain/authentication/authn_azure/authenticator.rb b/app/domain/authentication/authn_azure/authenticator.rb index f841644c30..1070ae00a8 100644 --- a/app/domain/authentication/authn_azure/authenticator.rb +++ b/app/domain/authentication/authn_azure/authenticator.rb @@ -36,7 +36,8 @@ def decoded_token claims_to_verify: { verify_iss: true, iss: provider_uri - } + }, + ca_cert: nil ), logger: @logger ) diff --git a/app/domain/authentication/authn_azure/validate_status.rb b/app/domain/authentication/authn_azure/validate_status.rb index 5aa33d6e0c..f97b2e4640 100644 --- a/app/domain/authentication/authn_azure/validate_status.rb +++ b/app/domain/authentication/authn_azure/validate_status.rb @@ -34,7 +34,8 @@ def required_variable_names def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: provider_uri + provider_uri: provider_uri, + ca_cert: nil ) end diff --git a/app/domain/authentication/authn_gcp/update_authenticator_input.rb b/app/domain/authentication/authn_gcp/update_authenticator_input.rb index 524f29b2d6..10b403f71d 100644 --- a/app/domain/authentication/authn_gcp/update_authenticator_input.rb +++ b/app/domain/authentication/authn_gcp/update_authenticator_input.rb @@ -50,7 +50,8 @@ def decoded_token iss: PROVIDER_URI, verify_iat: true, verify_expiration: true - } + }, + ca_cert: nil ), logger: @logger ) diff --git a/app/domain/authentication/authn_gcp/validate_status.rb b/app/domain/authentication/authn_gcp/validate_status.rb index 3bf517cb93..26afd56c13 100644 --- a/app/domain/authentication/authn_gcp/validate_status.rb +++ b/app/domain/authentication/authn_gcp/validate_status.rb @@ -15,7 +15,8 @@ def call def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: PROVIDER_URI + provider_uri: PROVIDER_URI, + ca_cert: nil ) end end diff --git a/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb b/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb index 9f2f35675a..ed7a7df71c 100644 --- a/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb +++ b/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb @@ -39,7 +39,8 @@ def discover_provider def discovered_provider @discovered_provider ||= @discover_identity_provider.call( - provider_uri: @provider_uri + provider_uri: @provider_uri, + ca_cert: nil ) end diff --git a/app/domain/authentication/authn_oidc/authenticator.rb b/app/domain/authentication/authn_oidc/authenticator.rb index 9f765b6ee4..17cfcccc06 100644 --- a/app/domain/authentication/authn_oidc/authenticator.rb +++ b/app/domain/authentication/authn_oidc/authenticator.rb @@ -39,7 +39,8 @@ def status(authenticator_status_input:) # If successful, validate the new set of required variables if authenticator.present? Authentication::AuthnOidc::ValidateStatus.new( - required_variable_names: %w[provider-uri client-id client-secret claim-mapping] + required_variable_names: %w[provider-uri client-id client-secret claim-mapping], + optional_variable_names: %w[ca-cert] ).( account: authenticator_status_input.account, service_id: authenticator_status_input.service_id diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index 5e98e78a42..99cc00fb1d 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -3,7 +3,6 @@ module AuthnOidc UpdateInputWithUsernameFromIdToken ||= CommandClass.new( dependencies: { - fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new, validate_account_exists: ::Authentication::Security::ValidateAccountExists.new, verify_and_decode_token: ::Authentication::OAuth::VerifyAndDecodeToken.new, logger: Rails.logger @@ -40,7 +39,8 @@ def verify_and_decode_token @decoded_token = @verify_and_decode_token.( provider_uri: oidc_authenticator_secrets["provider-uri"], token_jwt: decoded_credentials["id_token"], - claims_to_verify: {} # We don't verify any claims + claims_to_verify: {}, # We don't verify any claims + ca_cert: oidc_authenticator_secrets["ca-cert"] ) end @@ -86,7 +86,9 @@ def token_from_body end def oidc_authenticator_secrets - @oidc_authenticator_secrets ||= @fetch_authenticator_secrets.( + @oidc_authenticator_secrets ||= Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: optional_variable_names + ).( service_id: service_id, conjur_account: account, authenticator_name: authenticator_name, @@ -98,6 +100,10 @@ def required_variable_names @required_variable_names ||= %w[provider-uri id-token-user-property] end + def optional_variable_names + @optional_variable_names ||= %w[ca-cert] + end + def validate_conjur_username if conjur_username.empty? raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb index 80a82c00c2..5bf6cadf4c 100644 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ b/app/domain/authentication/authn_oidc/v2/client.rb @@ -97,6 +97,63 @@ def callback(code:, nonce:, code_verifier: nil) decoded_id_token end + # callback_with_temporary_cert wraps the callback method with commands + # to write & clean up a given certificate or cert chain in a given + # directory. By default, Conjur's default cert store is used. + # + # The temporary certificate file name is "x.n", where x is the hash of + # the certificate subject name, and n is incrememnted from 0 in case of + # collision. + # + # Unlike self.discover, which wraps a single ::OpenIDConnect method, + # callback_with_temporary_cert wraps the entire callback method, which + # includes multiple calls to the OIDC provider, including at least one + # discover! call. The temporary certs will apply to all required + # operations. + def callback_with_temporary_cert( + code:, + nonce:, + code_verifier: nil, + cert_dir: OpenSSL::X509::DEFAULT_CERT_DIR, + cert_string: nil + ) + c = -> { callback(code: code, nonce: nonce, code_verifier: code_verifier) } + + return c.call if cert_string.blank? + + begin + certs_a = ::Conjur::CertUtils.parse_certs(cert_string) + rescue OpenSSL::X509::CertificateError => e + raise Errors::Authentication::AuthnOidc::InvalidCertificate, e.message + end + raise Errors::Authentication::AuthnOidc::InvalidCertificate, "provided string does not contain a certificate" if certs_a.empty? + + symlink_a = [] + + Dir.mktmpdir do |tmp_dir| + certs_a.each_with_index do |cert, idx| + tmp_file = File.join(tmp_dir, "conjur-oidc-client.#{idx}.pem") + File.write(tmp_file, cert.to_s) + + n = 0 + hash = cert.subject.hash.to_s(16) + while true + symlink = File.join(cert_dir, "#{hash}.#{n}") + break unless File.exist?(symlink) + + n += 1 + end + + File.symlink(tmp_file, symlink) + symlink_a << symlink + end + + c.call + ensure + symlink_a.each{ |s| File.unlink(s) if s.present? && File.symlink?(s) } + end + end + def discovery_information(invalidate: false) @cache.fetch( "#{@authenticator.account}/#{@authenticator.service_id}/#{URI::Parser.new.escape(@authenticator.provider_uri)}", @@ -104,12 +161,66 @@ def discovery_information(invalidate: false) skip_nil: true ) do @discovery_configuration.discover!(@authenticator.provider_uri) - rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e + rescue Errno::ETIMEDOUT => e raise Errors::Authentication::OAuth::ProviderDiscoveryTimeout.new(@authenticator.provider_uri, e.message) rescue => e raise Errors::Authentication::OAuth::ProviderDiscoveryFailed.new(@authenticator.provider_uri, e.message) end end + + # discover wraps ::OpenIDConnect::Discovery::Provider::Config.discover! + # with commands to write & clean up a given certificate or cert chain in + # a given directory. By default, Conjur's default cert store is used. + # + # The temporary certificate file name is "x.n", where x is the hash of + # the certificate subject name, and n is incremented from 0 in case of + # collision. + # + # discover is a class method, because there are a few contexts outside + # this class where the underlying discover! method is used. Call it by + # running Authentication::AuthnOIDC::V2::Client.discover(...). + def self.discover( + provider_uri:, + discovery_configuration: ::OpenIDConnect::Discovery::Provider::Config, + cert_dir: OpenSSL::X509::DEFAULT_CERT_DIR, + cert_string: nil + ) + d = -> { discovery_configuration.discover!(provider_uri) } + + return d.call if cert_string.blank? + + begin + certs_a = ::Conjur::CertUtils.parse_certs(cert_string) + rescue OpenSSL::X509::CertificateError => e + raise Errors::Authentication::AuthnOidc::InvalidCertificate, e.message + end + raise Errors::Authentication::AuthnOidc::InvalidCertificate, "provided string does not contain a certificate" if certs_a.empty? + + symlink_a = [] + + Dir.mktmpdir do |tmp_dir| + certs_a.each_with_index do |cert, idx| + tmp_file = File.join(tmp_dir, "conjur-oidc-client.#{idx}.pem") + File.write(tmp_file, cert.to_s) + + n = 0 + hash = cert.subject.hash.to_s(16) + while true + symlink = File.join(cert_dir, "#{hash}.#{n}") + break unless File.exist?(symlink) + + n += 1 + end + + File.symlink(tmp_file, symlink) + symlink_a << symlink + end + + d.call + ensure + symlink_a.each{ |s| File.unlink(s) if s.present? && File.symlink?(s) } + end + end end end end diff --git a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb index 15f4bdffe5..b542b1fb7d 100644 --- a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb +++ b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb @@ -5,7 +5,7 @@ module DataObjects class Authenticator REQUIRED_VARIABLES = %i[provider_uri client_id client_secret claim_mapping].freeze - OPTIONAL_VARIABLES = %i[redirect_uri response_type provider_scope name token_ttl].freeze + OPTIONAL_VARIABLES = %i[redirect_uri response_type provider_scope name token_ttl ca_cert].freeze attr_reader( :provider_uri, @@ -15,7 +15,8 @@ class Authenticator :account, :service_id, :redirect_uri, - :response_type + :response_type, + :ca_cert ) def initialize( @@ -29,7 +30,8 @@ def initialize( name: nil, response_type: 'code', provider_scope: nil, - token_ttl: 'PT60M' + token_ttl: 'PT60M', + ca_cert: nil ) @account = account @provider_uri = provider_uri @@ -42,6 +44,7 @@ def initialize( @provider_scope = provider_scope @redirect_uri = redirect_uri @token_ttl = token_ttl + @ca_cert = ca_cert end def scope diff --git a/app/domain/authentication/authn_oidc/validate_status.rb b/app/domain/authentication/authn_oidc/validate_status.rb index eb24824df1..3d005a4b15 100644 --- a/app/domain/authentication/authn_oidc/validate_status.rb +++ b/app/domain/authentication/authn_oidc/validate_status.rb @@ -3,9 +3,9 @@ module AuthnOidc ValidateStatus = CommandClass.new( dependencies: { - fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new, discover_identity_provider: Authentication::OAuth::DiscoverIdentityProvider.new, - required_variable_names: %w[provider-uri id-token-user-property] + required_variable_names: %w[provider-uri id-token-user-property], + optional_variable_names: %w[ca-cert] }, inputs: %i[account service_id] ) do @@ -26,7 +26,9 @@ def validate_secrets end def oidc_authenticator_secrets - @oidc_authenticator_secrets ||= @fetch_authenticator_secrets.( + @oidc_authenticator_secrets ||= Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: @optional_variable_names + ).( service_id: @service_id, conjur_account: @account, authenticator_name: "authn-oidc", @@ -36,13 +38,18 @@ def oidc_authenticator_secrets def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: provider_uri + provider_uri: provider_uri, + ca_cert: ca_cert ) end def provider_uri @oidc_authenticator_secrets["provider-uri"] end + + def ca_cert + @oidc_authenticator_secrets["ca-cert"] + end end end end diff --git a/app/domain/authentication/o_auth/discover_identity_provider.rb b/app/domain/authentication/o_auth/discover_identity_provider.rb index aeb89794f9..9a78d26e6c 100644 --- a/app/domain/authentication/o_auth/discover_identity_provider.rb +++ b/app/domain/authentication/o_auth/discover_identity_provider.rb @@ -6,7 +6,7 @@ module OAuth logger: Rails.logger, open_id_discovery_service: OpenIDConnect::Discovery::Provider::Config }, - inputs: %i[provider_uri] + inputs: %i[provider_uri ca_cert] ) do def call log_provider_uri @@ -33,7 +33,7 @@ def discover_provider LogMessages::Authentication::OAuth::IdentityProviderDiscoverySuccess.new ) @discovered_provider - rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e + rescue Errno::ETIMEDOUT => e raise_error(Errors::Authentication::OAuth::ProviderDiscoveryTimeout, e) rescue => e raise_error(Errors::Authentication::OAuth::ProviderDiscoveryFailed, e) diff --git a/app/domain/authentication/o_auth/fetch_provider_keys.rb b/app/domain/authentication/o_auth/fetch_provider_keys.rb index 3f021423c5..4c045de8e3 100644 --- a/app/domain/authentication/o_auth/fetch_provider_keys.rb +++ b/app/domain/authentication/o_auth/fetch_provider_keys.rb @@ -8,7 +8,7 @@ module OAuth logger: Rails.logger, discover_identity_provider: DiscoverIdentityProvider.new }, - inputs: %i[provider_uri] + inputs: %i[provider_uri ca_cert] ) do def call discover_provider @@ -23,7 +23,8 @@ def discover_provider def discovered_provider @discovered_provider ||= @discover_identity_provider.( - provider_uri: @provider_uri + provider_uri: @provider_uri, + ca_cert: @ca_cert ) end diff --git a/app/domain/authentication/o_auth/verify_and_decode_token.rb b/app/domain/authentication/o_auth/verify_and_decode_token.rb index da87403def..13d16a16cb 100644 --- a/app/domain/authentication/o_auth/verify_and_decode_token.rb +++ b/app/domain/authentication/o_auth/verify_and_decode_token.rb @@ -23,7 +23,7 @@ module OAuth verify_and_decode_token: ::Authentication::Jwt::VerifyAndDecodeToken.new, logger: Rails.logger }, - inputs: %i[provider_uri token_jwt claims_to_verify] + inputs: %i[provider_uri token_jwt claims_to_verify ca_cert] ) do def call fetch_provider_keys @@ -35,7 +35,8 @@ def call def fetch_provider_keys(force_read: false) provider_keys = @fetch_provider_keys.call( provider_uri: @provider_uri, - refresh: force_read + refresh: force_read, + ca_cert: @ca_cert ) @jwks = provider_keys.jwks diff --git a/app/domain/authentication/util/fetch_authenticator_secrets.rb b/app/domain/authentication/util/fetch_authenticator_secrets.rb index 32f97028cd..3ae11f7368 100644 --- a/app/domain/authentication/util/fetch_authenticator_secrets.rb +++ b/app/domain/authentication/util/fetch_authenticator_secrets.rb @@ -6,25 +6,35 @@ module Util FetchAuthenticatorSecrets = CommandClass.new( dependencies: { - fetch_secrets: ::Conjur::FetchRequiredSecrets.new + fetch_required_secrets: ::Conjur::FetchRequiredSecrets.new, + fetch_optional_secrets: ::Conjur::FetchOptionalSecrets.new, + optional_variable_names: [] }, inputs: %i[conjur_account authenticator_name service_id required_variable_names] ) do def call - @required_variable_names.each_with_object({}) do |variable_name, secrets| - full_variable_name = full_variable_name(variable_name) - secrets[variable_name] = required_secrets[full_variable_name] - end + secret_map_for(required_secrets).merge(secret_map_for(optional_secrets)) end private def required_secrets - @required_secrets ||= @fetch_secrets.(resource_ids: required_resource_ids) + @required_secrets ||= @fetch_required_secrets.(resource_ids: resource_ids_for(@required_variable_names)) + end + + def optional_secrets + @optional_secrets ||= @fetch_optional_secrets.(resource_ids: resource_ids_for(@optional_variable_names)) + end + + def secret_map_for(secret_values) + secret_values.each_with_object({}) do |(full_name, value), secrets| + short_name = full_name.to_s.split('/')[-1] + secrets[short_name] = value + end end - def required_resource_ids - @required_variable_names.map { |var_name| full_variable_name(var_name) } + def resource_ids_for(variable_names) + variable_names.map { |var_name| full_variable_name(var_name) } end def full_variable_name(var_name) diff --git a/app/domain/conjur/fetch_optional_secrets.rb b/app/domain/conjur/fetch_optional_secrets.rb new file mode 100644 index 0000000000..0c3ab588e2 --- /dev/null +++ b/app/domain/conjur/fetch_optional_secrets.rb @@ -0,0 +1,31 @@ +require 'command_class' + +module Conjur + + FetchOptionalSecrets ||= CommandClass.new( + dependencies: { resource_class: ::Resource }, + inputs: [:resource_ids] + ) do + def call + secret_values + end + + private + + def secret_values + secrets.transform_values do |secret| + secret ? secret.value : nil + end + end + + def resources + @resources ||= @resource_ids.map { |id| [id, @resource_class[id]] }.to_h + end + + def secrets + @secrets ||= resources.transform_values do |resource| + resource ? resource.secret : nil + end + end + end +end diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 6593562702..3ffa1418cb 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -275,6 +275,11 @@ module AuthnOidc msg: "Access Token retrieval failure: '{0-error}'", code: "CONJ00133E" ) + + InvalidCertificate = ::Util::TrackableErrorClass.new( + msg: "Invalid certificate: {0-message}", + code: "CONJ00135E" + ) end module AuthnIam diff --git a/app/domain/factories/Readme.md b/app/domain/factories/Readme.md new file mode 100644 index 0000000000..a5a95b519c --- /dev/null +++ b/app/domain/factories/Readme.md @@ -0,0 +1,555 @@ +# Policy Factory + +## Setup + +Setup will follow the following workflow: + +![Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## Factory Upgrade + +Upgrades will follow the following workflow: + +![Factory Upgrade](./images/factory-upgrade.png) + +```plantuml +@startuml factory-upgrade +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy with new factory versions; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## View all Policy Factories + +A role is limited to viewing the Factories they have permission (`execute`) to see. +If a role can see a factory, they will be able to see errors in mis-configured Factories. + +![Factory List Request](./images/factory-list-request.png) + +```plantuml +@startuml factory-list-request +start +:Identify target Factory based on request params; +:Gather factories the role is able to view; +partition "For each Factory Version" { + repeat + if ("Factory is present?") then (yes) + if ("Is Factory format is valid?") then (yes) + if ("Is Factory Schema is valid?") then (yes) + :Display Factory details and Schema; + else + #pink:[Error] Invalid Factory Schema; + endif + else + #pink:[Error] Invalid Factory Format; + endif + else + #pink:[Error] Factory not Defined; + endif + backward: Next Factory; + repeat while (More Factories?) +} +:Return JSON Summary; +@enduml +``` + +## Policy Factory Info Requests + +![Factory Info Request](./images/factory-info-request.png) + +```plantuml +@startuml factory-info-request +(*) --> "Identify target Factory based on request params" +if "Does Factory exist?" then + --> [yes] if "Role has permission to view factory" then + --> [yes] if "Factory is present?" then + --> "Load Factory" + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Return Schema" + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + endif + else + --> [no] "[Error] Factory not Available" + endif +else + --> [no] "[Error] Factory not Found" +endif +@enduml + +``` + +## Policy Factory Creation Requests + +![Factory Create Request](./images/factory-create-request.png) + +```plantuml +@startuml factory-create-request +(*) --> "Identify Factory variable based on request params" +if "Does factory variable exist?" then + --> [yes] if "Can role load factory?" then + --> [yes] "Load Factory" + --> [yes] if "Does factory variable have a value?" then + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Extract Schema from Factory Variable" + --> "Parse [POST] JSON Request body" + --> if "is JSON valid?" + --> [yes] if "Required keys present?" + --> [yes] if "Required values present?" + --> [yes] if "Policy rendered successfully?" + --> [yes] if "Policy namespace path rendered successfully?" + --> [yes] if "Policy successfully applied" + --> [yes] if "Factory has variables?" + --> [yes] if "Variables set successfully set?" + --> "Return Policy and Variable response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + --> (*) + else + --> [no] "[Error] Setting Variable(s) not Permitted" + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + endif + else + --> [no] " Policy Created Response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + endif + else + --> [no] "[Error] Policy Creation not Permitted" + ' note left + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + endif + else + --> [no] "[Error] Invalid Factory Namespace ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Invalid Factory Policy ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Missing Required Values" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Missing Required Keys" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Bad Request Body" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + endif + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + endif + else + --> [no] "[Error] Factory not Available" + ' note left + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + endif +else + --> [no] "[Error] Factory not Found" + ' note left + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note +endif +@enduml +``` + +## Policy Factory Creation Requests (beta) + +![Policy Factory Create Request](./images/Readme-5.png) + +```plantuml +@startuml +start +:Identify Factory\nvariable based\non request params; +if (Does factory variable exist?) then (yes) + if (Can role load factory variable?) then (yes) + if (Does factory variable have a value?) then (yes) + :Load Factory; + :Extract Schema from Factory Variable; + :Parse [POST] JSON Request body; + ' :Extract Schema from Factory; + if (Parse JSON body?) then (yes) + if (Required keys missing?) then (no) + #pink: Missing Keys; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (required values empty?) then (no) + #pink: Missing Values; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (Policy rendered?) then (yes) + if (Policy namespace path rendered?) then (yes) + if (Policy successfully applied) then (yes) + if (Factory has variables?) then (yes) + if (Variable successfully set?) then (yes) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + end + else (no) + #pink: Setting Variable(s) not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + kill + endif + else (no) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + kill + endif + else (no) + #pink: Policy Creation not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + kill + endif + else (no) + #pink: Invalid Policy Namespace ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + kill + endif + else (no) + #pink: Invalid Policy ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + kill + endif + endif + endif + else (no) + #pink: Malformed JSON; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + kill + endif + else (no) + #pink: Factory Variable empty; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + kill + endif + else (no) + #pink: Factory not available; + ' note right + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + kill + endif +else (no) + #pink: Factory Variable not present; + ' note right + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note + kill +endif +@enduml +``` + +### UI Workflow + +![UI Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Login; +:Navigate to "Policy Factories" page; +if (Can view Factories) then (yes) + :Show Factory Groupings; + :Navigate to Factory Grouping; + :Select a Factory; + if ("Can view Factory") then (yes) + :View Factory form; + if ("Factory successfully created") then (yes) + :Redirect + else + end + else + end +else (no) + :Show empty Factories page\nwith "No Factories Available"; +end +@enduml +``` + +## Code Architecture + +![Basic Overview](./images/Basic-Sample.png) + +```plantuml +@startuml Basic Sample +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +Component(controller, "PolicyFactoryController", "Rails", "Routes requests to Business Logic and renders results") + +Component(repository, "PolicyFactoryRepository", "Ruby", "Retrieves Factories from Conjur Variables") + +Component(data_object, "DataObjects::PolicyFactory", "Ruby") + +Component(create, "CreateFromPolicyFactory", "Ruby", "Generates Conjur elements using a Policy Factory") + +Rel(repository, controller, "loads factory from") + +' Component(repository 'PolicyFactoryRepository') + +' component PolicyFactoryController +' component PolicyFactoryRepository +@enduml +``` diff --git a/app/domain/factories/create_from_policy_factory.rb b/app/domain/factories/create_from_policy_factory.rb new file mode 100644 index 0000000000..d1e0fcdcda --- /dev/null +++ b/app/domain/factories/create_from_policy_factory.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require 'rest_client' +require 'json_schemer' +require 'factories/renderer' + +module Factories + class Utilities + def self.filter_input(str) + str.gsub(/[^0-9a-z\-_]/i, '') + end + end + class CreateFromPolicyFactory + def initialize(renderer: Factories::Renderer.new, http: RestClient, schema_validator: JSONSchemer, utilities: Factories::Utilities) + @renderer = renderer + @http = http + @schema_validator = schema_validator + @utilities = utilities + + # JSON and URI are defined here for visibility. They are not currently + # mocked in testing, thus, we're not setting them in the initializer. + @json = JSON + @uri = URI + + # Defined here for visibility. We shouldn't need to mock these. + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def call(factory_template:, request_body:, account:, authorization:) + validate_and_transform_request( + schema: factory_template.schema, + params: request_body + ).bind do |body_variables| + # Convert `dashed` keys to `underscored`. This only occurs for top-level parameters. + # Conjur variables should be use dashes rather than underscores. + # Filter non-alpha-numeric, dash or underscore characters from inputs values (to prevent injection attacks). + template_variables = body_variables + .transform_keys { |key| key.to_s.underscore } + .each_with_object({}) do |(key, value), rtn| + # Only strip values that are rendered in the policy (not Conjur secret values) + rtn[key] = if key == 'variables' + value + elsif value.is_a?(Hash) + value.transform_values { |internal_value| @utilities.filter_input(internal_value.to_s) } + else + @utilities.filter_input(value.to_s) + end + end + + # Add empty `annotations` hash unless they've previously been set + template_variables["annotations"] = {} unless template_variables.include?('annotations') + + # Push rendered policy to the desired policy branch + @renderer.render(template: factory_template.policy_branch, variables: template_variables) + .bind do |policy_load_path| + valid_variables = factory_template.schema['properties'].keys - ['variables'] + render_and_apply_policy( + policy_load_path: policy_load_path, + policy_template: factory_template.policy, + variables: template_variables.select { |k, _| valid_variables.include?(k) }, + account: account, + authorization: authorization + ).bind do |result| + return @success.new(result) unless factory_template.schema['properties'].key?('variables') + + # Set Policy Factory variables + variables_path = ["<%= id %>"] + # If the variables are headed for the "root" namespace, we don't want the namespace in the path + variables_path.prepend(factory_template.policy_branch) unless policy_load_path == 'root' + @renderer.render(template: variables_path.join('/'), variables: template_variables) + .bind do |variable_path| + set_factory_variables( + schema_variables: factory_template.schema['properties']['variables']['properties'], + factory_variables: template_variables['variables'], + variable_path: variable_path, + authorization: authorization, + account: account + ) + end + .bind do + # If variables were added successfully, return the result so that + # we send the policy load response back to the client. + @success.new(result) + end + end + end + end + end + + private + + def validate_and_transform_request(schema:, params:) + return @failure.new('Request body must be JSON', status: :bad_request) if params.blank? + + begin + params = @json.parse(params) + rescue + return @failure.new('Request body must be valid JSON', status: :bad_request) + end + + # Strip keys without values + params = params.select{|_, v| v.present? } + validator = @schema_validator.schema(schema) + return @success.new(params) if validator.valid?(params) + + errors = validator.validate(params).map do |error| + case error['type'] + when 'required' + missing_attributes = error['details']['missing_keys'].map{|key| [ error['data_pointer'], key].reject(&:empty?).join('/') } #.join("', '") + missing_attributes.map do |attribute| + { + message: "A value is required for '#{attribute}'", + key: attribute + } + end + else + { + message: "Validation error: '#{error['data_pointer']}' must be a #{error['type']}" + } + end + end + @failure.new(errors.flatten, status: :bad_request) + end + + def render_and_apply_policy(policy_load_path:, policy_template:, variables:, account:, authorization:) + @renderer.render( + template: policy_template, + variables: variables + ).bind do |rendered_policy| + begin + response = @http.post( + "http://localhost:3000/policies/#{account}/policy/#{policy_load_path}", + rendered_policy, + 'Authorization' => authorization + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new( + { message: 'Authentication failed', + request_error: e.response.body }, status: :unauthorized + ) + when 403 + return @failure.new( + { message: "Applying generated policy to '#{policy_load_path}' is not allowed", + request_error: e.response.body }, status: :forbidden + ) + when 404 + return @failure.new( + { message: "Unable to apply generated policy to '#{policy_load_path}'", + request_error: e.response.body }, status: :not_found + ) + else + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + + @success.new(response.body) + end + end + + def set_factory_variables(schema_variables:, factory_variables:, variable_path:, authorization:, account:) + # Only set secrets defined in the policy + schema_variables.each_key do |factory_variable| + variable_id = @uri.encode_www_form_component("#{variable_path}/#{factory_variable}") + secret_path = "secrets/#{account}/variable/#{variable_id}" + + @http.post( + "http://localhost:3000/#{secret_path}", + factory_variables[factory_variable].to_s, + { 'Authorization' => authorization } + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new("Role is unauthorized to set variable: '#{secret_path}'", status: :unauthorized) + when 403 + return @failure.new("Role lacks the privilege to set variable: '#{secret_path}'", status: :forbidden) + else + return @failure.new( + "Failed to set variable: '#{secret_path}'. Status Code: '#{e.response.code}', Response: '#{e.response.body}'", + status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed set variable '#{secret_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + @success.new('Variables successfully set') + end + end +end diff --git a/app/domain/factories/images/Basic-Sample.png b/app/domain/factories/images/Basic-Sample.png new file mode 100644 index 0000000000..1f51593332 Binary files /dev/null and b/app/domain/factories/images/Basic-Sample.png differ diff --git a/app/domain/factories/images/Readme-5.png b/app/domain/factories/images/Readme-5.png new file mode 100644 index 0000000000..21aedc9a66 Binary files /dev/null and b/app/domain/factories/images/Readme-5.png differ diff --git a/app/domain/factories/images/factory-create-request.png b/app/domain/factories/images/factory-create-request.png new file mode 100644 index 0000000000..d27a669fa7 Binary files /dev/null and b/app/domain/factories/images/factory-create-request.png differ diff --git a/app/domain/factories/images/factory-info-request.png b/app/domain/factories/images/factory-info-request.png new file mode 100644 index 0000000000..bceab0368c Binary files /dev/null and b/app/domain/factories/images/factory-info-request.png differ diff --git a/app/domain/factories/images/factory-list-request.png b/app/domain/factories/images/factory-list-request.png new file mode 100644 index 0000000000..9559793b65 Binary files /dev/null and b/app/domain/factories/images/factory-list-request.png differ diff --git a/app/domain/factories/images/factory-setup.png b/app/domain/factories/images/factory-setup.png new file mode 100644 index 0000000000..4d01a6b645 Binary files /dev/null and b/app/domain/factories/images/factory-setup.png differ diff --git a/app/domain/factories/images/factory-upgrade.png b/app/domain/factories/images/factory-upgrade.png new file mode 100644 index 0000000000..05e0f50bfb Binary files /dev/null and b/app/domain/factories/images/factory-upgrade.png differ 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..f71f3460e0 --- /dev/null +++ b/app/domain/factories/templates/authenticators/v1/authn_oidc.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Authenticators + module V1 + class AuthnOidc + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: authenticators/v1/authn-oidc + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + + body: + - !webservice + + - !variable provider-uri + - !variable client-id + - !variable client-secret + - !variable redirect-uri + - !variable claim-mapping + + - !group + id: authenticatable + annotations: + description: Group with permission to authenticate using this authenticator + + - !permit + role: !group authenticatable + privilege: [ read, authenticate ] + resource: !webservice + + - !webservice + id: status + annotations: + description: Web service for checking authenticator status + + - !group + id: operators + annotations: + description: Group with permission to check the authenticator status + + - !permit + role: !group operators + privilege: [ read ] + resource: !webservice status + TEMPLATE + end + + def data + Base64.encode64({ + version: 'v1', + policy: Base64.encode64(policy_template), + policy_branch: "conjur/authn-oidc", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Authn-OIDC Template", + "description": "Create a new Authn-OIDC Authenticator", + "type": "object", + "properties": { + "id": { + "description": "Service ID of the Authenticator", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + }, + "variables": { + "type": "object", + "properties": { + "provider-uri": { + "description": "OIDC Provider endpoint", + "type": "string" + }, + "client-id": { + "description": "OIDC Client ID", + "type": "string" + }, + "client-secret": { + "description": "OIDC Client Secret", + "type": "string" + }, + "redirect-uri": { + "description": "Target URL to redirect to after successful authentication", + "type": "string" + }, + "claim-mapping": { + "description": "OIDC JWT claim mapping. This value must match to a Conjur Host ID.", + "type": "string" + } + }, + "required": %w[provider-uri client-id client-secret claim-mapping] + } + }, + "required": %w[id variables] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/base/v1/base_policy.rb b/app/domain/factories/templates/base/v1/base_policy.rb new file mode 100644 index 0000000000..f114963d2f --- /dev/null +++ b/app/domain/factories/templates/base/v1/base_policy.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Factories + module Templates + module Base + module V1 + class BasePolicy + class << self + def policy + <<~TEMPLATE + - !policy + id: conjur + body: + - !policy + id: factories + body: + - !policy + id: core + annotations: + description: "Create Conjur primatives and manage permissions" + body: + - !variable v1/grant + - !variable v1/group + - !variable v1/host + - !variable v1/layer + - !variable v1/managed-policy + - !variable v1/policy + - !variable v1/user + + - !policy + id: authenticators + annotations: + description: "Generate new Authenticators" + body: + - !variable v1/authn-oidc + - !policy + id: connections + annotations: + description: "Create connections to external services" + body: + - !variable v1/database + - !variable v2/database + TEMPLATE + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/connections/v1/database.rb b/app/domain/factories/templates/connections/v1/database.rb new file mode 100644 index 0000000000..6a7b01ab02 --- /dev/null +++ b/app/domain/factories/templates/connections/v1/database.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Connections + module V1 + class Database + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: connections/v1/database + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + + body: + - &variables + - !variable url + - !variable port + - !variable username + - !variable password + + - !group consumers + - !group administrators + + # consumers can read and execute + - !permit + resource: *variables + privileges: [ read, execute ] + role: !group consumers + + # administrators can update (and read and execute, via role grant) + - !permit + resource: *variables + privileges: [ update ] + role: !group administrators + + # administrators has role consumers + - !grant + member: !group administrators + role: !group consumers + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Database Connection Template", + "description": "All information for connecting to a database", + "type": "object", + "properties": { + "id": { + "description": "Database Connection Identifier", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this connection into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + }, + "variables": { + "type": "object", + "properties": { + "url": { + "description": "Database URL", + "type": "string" + }, + "port": { + "description": "Database Port", + "type": "string" + }, + "username": { + "description": "Database Username", + "type": "string" + }, + "password": { + "description": "Database Password", + "type": "string" + }, + }, + "required": %w[url port username password] + } + }, + "required": %w[id branch variables] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/grant.rb b/app/domain/factories/templates/core/v1/grant.rb new file mode 100644 index 0000000000..6fdc2613d5 --- /dev/null +++ b/app/domain/factories/templates/core/v1/grant.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Grant + class << self + def policy_template + <<~TEMPLATE + - !grant + member: !<%= member_resource_type %> <%= member_resource_id %> + role: !<%= role_resource_type %> <%= role_resource_id %> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Grant Template", + "description": "Assigns a Role to another Role", + "type": "object", + "properties": { + "branch": { + "description": "Policy branch to load this grant into", + "type": "string" + }, + "member_resource_type": { + "description": "The member type (group, host, user, etc.) for the grant", + "type": "string" + }, + "member_resource_id": { + "description": "The member resource identifier for the grant", + "type": "string" + }, + "role_resource_type": { + "description": "The role type (group, host, user, etc.) for the grant", + "type": "string" + }, + "role_resource_id": { + "description": "The role resource identifier for the grant", + "type": "string" + } + }, + "required": %w[branch member_resource_type member_resource_id role_resource_type role_resource_id] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/group.rb b/app/domain/factories/templates/core/v1/group.rb new file mode 100644 index 0000000000..c299b9e356 --- /dev/null +++ b/app/domain/factories/templates/core/v1/group.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Group + class << self + def policy_template + <<~TEMPLATE + - !group + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + annotations: + factory: core/v1/group + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Group Template", + "description": "Creates a Conjur Group", + "type": "object", + "properties": { + "id": { + "description": "Group Identifier", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this group into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this group", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this group", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/managed_policy.rb b/app/domain/factories/templates/core/v1/managed_policy.rb new file mode 100644 index 0000000000..84095c7f35 --- /dev/null +++ b/app/domain/factories/templates/core/v1/managed_policy.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class ManagedPolicy + class << self + def policy_template + <<~TEMPLATE + - !group <%= name %>-admins + - !policy + id: <%= name %> + owner: !group <%= name %>-admins + annotations: + factory: core/v1/managed-policy + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Managed Policy Template", + "description": "Policy with an owner group", + "type": "object", + "properties": { + "name": { + "description": "Policy name (used to create the policy ID and the -admins owner group)", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this policy into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[name branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/policy.rb b/app/domain/factories/templates/core/v1/policy.rb new file mode 100644 index 0000000000..a5d8aad9a3 --- /dev/null +++ b/app/domain/factories/templates/core/v1/policy.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Policy + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + annotations: + factory: core/v1/policy + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "User Template", + "description": "Creates a Conjur Policy", + "type": "object", + "properties": { + "id": { + "description": "Policy ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this policy into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this policy", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this policy", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/user.rb b/app/domain/factories/templates/core/v1/user.rb new file mode 100644 index 0000000000..c293a30d70 --- /dev/null +++ b/app/domain/factories/templates/core/v1/user.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class User + class << self + def policy_template + <<~TEMPLATE + - !user + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + <% if defined?(ip_range) -%> + restricted_to: <%= ip_range %> + <% end -%> + annotations: + factory: core/v1/user + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "User Template", + "description": "Creates a Conjur User", + "type": "object", + "properties": { + "id": { + "description": "User ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this user into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this user", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this user", + "type": "string" + }, + "ip_range": { + "description": "Limits the network range the user is allowed to authenticate from", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/responses.rb b/app/domain/responses.rb new file mode 100644 index 0000000000..4fc9948f17 --- /dev/null +++ b/app/domain/responses.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# These response objects provide a mechanism for passing more complex response +# information upstream + +# Responsible for handling "successful" requests. The +# response is returned via the `.result` method. +class SuccessResponse + attr_reader :result + + def initialize(result) + @result = result + end + + def success? + true + end + + # The result of bind should always be another Response object, if the current + # response object is successful, #bind will call the next operation + def bind(&_block) + yield(result) + end +end + +# Responsible for handling "failed" requests. +# Log level and Response code are both option. +class FailureResponse + attr_reader :message, :status + + def initialize(message, level: :warn, status: :unauthorized) + @message = message + @level = level + @status = status + end + + def success? + false + end + + def level + @level.to_sym + end + + def to_s + @message.to_s + end + + # If the current response is a failure, further attempts to bind will just + # return this response again. + def bind + self + end +end diff --git a/app/models/exceptions/disallowed_policy_operation.rb b/app/models/exceptions/disallowed_policy_operation.rb new file mode 100644 index 0000000000..84d34f0bbf --- /dev/null +++ b/app/models/exceptions/disallowed_policy_operation.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Exceptions + class DisallowedPolicyOperation < RuntimeError + + def initialize + super("Updating existing resource disallowed in additive policy operation") + end + + end +end diff --git a/app/models/loader/create_policy.rb b/app/models/loader/create_policy.rb index f3aac3dc67..128a179edc 100644 --- a/app/models/loader/create_policy.rb +++ b/app/models/loader/create_policy.rb @@ -16,7 +16,7 @@ def call @loader.delete_shadowed_and_duplicate_rows - @loader.store_policy_in_db + @loader.store_policy_in_db(reject_duplicates: true) @loader.release_db_connection end diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index fce6f491e5..c5e408a31d 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -120,8 +120,9 @@ def delete_shadowed_and_duplicate_rows end # TODO: consider renaming this method - def store_policy_in_db - eliminate_duplicates_pk + def store_policy_in_db(reject_duplicates: false) + removed_duplicates_count = eliminate_duplicates_pk + raise Exceptions::DisallowedPolicyOperation if removed_duplicates_count.positive? && reject_duplicates insert_new @@ -243,8 +244,9 @@ def eliminate_duplicates_exact end # Delete rows from the new policy which have the same primary keys as existing rows. + # Returns the total number of deleted rows. def eliminate_duplicates_pk - TABLES.each do |table| + TABLES.sum do |table| eliminate_duplicates(table, Array(model_for_table(table).primary_key) + [ :policy_id ]) end end diff --git a/app/presenters/policy_factories/error.rb b/app/presenters/policy_factories/error.rb new file mode 100644 index 0000000000..ed84a45ca0 --- /dev/null +++ b/app/presenters/policy_factories/error.rb @@ -0,0 +1,28 @@ +module Presenter + module PolicyFactories + # Returns a Hash representation of an Failure Response to be used by the controller + class Error + # Response is always a FailureResponse + def initialize(response:, response_codes: HTTP::Response::Status::SYMBOL_CODES) + @response = response + @response_codes = response_codes + end + + def present + { + code: @response_codes[@response.status] + }.tap do |rtn| + rtn[:error] = format_error_message(@response.message) + end + end + + private + + def format_error_message(message) + return message if message.is_a?(Array) || message.is_a?(Hash) + + { message: message.to_s } + end + end + end +end diff --git a/app/presenters/policy_factories/index.rb b/app/presenters/policy_factories/index.rb new file mode 100644 index 0000000000..36fea65755 --- /dev/null +++ b/app/presenters/policy_factories/index.rb @@ -0,0 +1,35 @@ +module Presenter + module PolicyFactories + # returns a Hash representation to be used by the controller + class Index + def initialize(factories:) + @factories = factories + end + + def present + {}.tap do |rtn| + @factories + .group_by(&:classification) + .sort_by {|classification, _| classification } + .map do |classification, factories| + rtn[classification] = factories + .map { |factory| factory_to_hash(factory) } + .sort { |x, y| x[:name] <=> y[:name] } + end + end + end + + private + + def factory_to_hash(factory) + { + name: factory.name, + namespace: factory.classification, + 'full-name': "#{factory.classification}/#{factory.name}", + 'current-version': factory.version, + description: factory.description || '' + } + end + end + end +end diff --git a/app/presenters/policy_factories/show.rb b/app/presenters/policy_factories/show.rb new file mode 100644 index 0000000000..1bfdcd2f00 --- /dev/null +++ b/app/presenters/policy_factories/show.rb @@ -0,0 +1,20 @@ +module Presenter + module PolicyFactories + # returns a hash representation to be used by the controller + class Show + def initialize(factory:) + @factory = factory + end + + def present + { + title: @factory.schema['title'], + version: @factory.version, + description: @factory.schema['description'], + properties: @factory.schema['properties'], + required: @factory.schema['required'] + } + end + end + end +end diff --git a/app/views/status/index.html.erb b/app/views/status/index.html.erb index a60957aae3..a36a3c2c10 100644 --- a/app/views/status/index.html.erb +++ b/app/views/status/index.html.erb @@ -24,7 +24,7 @@

Status

Your Conjur server is running!

- +

Security Check:

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

@@ -70,7 +70,7 @@
- +