diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f3642523..ea39caa348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Telemetry support [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) +- Introduces support for Policy Factory, which enables resource creation + through a new `factories` API. + [cyberark/conjur#2855](https://github.com/cyberark/conjur/pull/2855/files) + +## [1.19.6] - 2023-07-05 + +### Added - New flag to `conjurctl server` command called `--no-migrate` which allows for skipping the database migration step when starting the server. [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) diff --git a/Gemfile b/Gemfile index c736397b45..13ded1732c 100644 --- a/Gemfile +++ b/Gemfile @@ -76,7 +76,7 @@ gem 'openid_connect' gem "anyway_config" gem 'i18n', '~> 1.8.11' - +gem 'json_schemer' gem 'prometheus-client' group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index b63a5c5945..ab912fa324 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,8 +75,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) @@ -116,7 +116,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 @@ -207,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) @@ -222,6 +224,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) @@ -230,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) @@ -249,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) @@ -324,7 +332,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.7.1) @@ -387,6 +395,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) @@ -471,8 +480,9 @@ GEM concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.1) + unf_ext (0.0.8.2) unicode-display_width (1.8.0) + uri_template (0.7.0) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -531,6 +541,7 @@ DEPENDENCIES i18n (~> 1.8.11) iso8601 jbuilder (~> 2.7.0) + json_schemer json_spec (~> 1.1) jwt (= 2.2.2) kubeclient diff --git a/app/controllers/policy_factories_controller.rb b/app/controllers/policy_factories_controller.rb new file mode 100644 index 0000000000..0e73566d4d --- /dev/null +++ b/app/controllers/policy_factories_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require './app/domain/responses' + +class PolicyFactoriesController < RestController + include AuthorizeResource + + before_action :current_user + + def create + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(%i[account kind version id]) + ).bind do |factory| + Factories::CreateFromPolicyFactory.new.call( + account: params[:account], + factory_template: factory, + request_body: request.body.read, + authorization: request.headers["Authorization"] + ) + end + + render_response(response) do + render(json: response.result) + end + end + + def show + allowed_params = %i[account kind version id] + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(allowed_params) + ) + + render_response(response) do + presenter = Presenter::PolicyFactories::Show.new(factory: response.result) + render(json: presenter.present) + end + end + + def index + response = DB::Repository::PolicyFactoryRepository.new.find_all( + role: current_user, + account: params[:account] + ) + render_response(response) do + presenter = Presenter::PolicyFactories::Index.new(factories: response.result) + render(json: presenter.present) + end + end + + private + + def render_response(response, &block) + if response.success? + block.call + else + presenter = Presenter::PolicyFactories::Error.new(response: response) + render( + json: presenter.present, + status: response.status + ) + end + end + + def relevant_params(allowed_params) + params.permit(*allowed_params).slice(*allowed_params).to_h.symbolize_keys + end +end diff --git a/app/db/repository/policy_factory_repository.rb b/app/db/repository/policy_factory_repository.rb new file mode 100644 index 0000000000..61bb003a22 --- /dev/null +++ b/app/db/repository/policy_factory_repository.rb @@ -0,0 +1,131 @@ +require 'base64' +require 'json' + +require './app/domain/responses' + +module DB + module Repository + module DataObjects + PolicyFactory = Struct.new( + :name, + :classification, + :version, + :policy, + :policy_branch, + :schema, + :description, + keyword_init: true + ) + end + + class PolicyFactoryRepository + def initialize( + data_object: DataObjects::PolicyFactory, + resource: ::Resource, + logger: Rails.logger + ) + @resource = resource + @data_object = data_object + @logger = logger + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def find_all(account:, role:) + factories = @resource.visible_to(role).where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/%" + ) + ).all + .select { |factory| role.allowed_to?(:execute, factory) } + .group_by do |item| + # form is: 'conjur/factories/core/v1/groups' + _, _, classification, _, factory = item.resource_id.split('/') + [classification, factory].join('/') + end + .map do |_, versions| + versions.max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + .map do |factory| + response = secret_to_data_object(factory) + response.result if response.success? + end + .compact + + if factories.empty? + return @failure.new( + 'Role does not have permission to use Factories', + status: :forbidden + ) + end + + @success.new(factories) + end + + def find(kind:, id:, account:, role:, version: nil) + factory = if version.present? + @resource["#{account}:variable:conjur/factories/#{kind}/#{version}/#{id}"] + else + @resource.where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/#{kind}/%" + ) + ).all + .select { |i| i.resource_id.split('/').last == id } + .max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + + resource_id = "#{kind}/#{version || 'v1'}/#{id}" + + if factory.blank? + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory does not exist' }, + status: :not_found + ) + elsif !role.allowed_to?(:execute, factory) + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory is not available' }, + status: :forbidden + ) + else + secret_to_data_object(factory) + end + end + + private + + def factory_version(factory_id) + version_match = factory_id.match(%r{/v(\d+)/[\w-]+}) + return 0 if version_match.nil? + + version_match[1].to_i + end + + def secret_to_data_object(variable) + _, _, classification, version, id = variable.resource_id.split('/') + factory = variable.secret&.value + if factory + decoded_factory = JSON.parse(Base64.decode64(factory)) + @success.new( + @data_object.new( + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'], + schema: decoded_factory['schema'], + version: version, + name: id, + classification: classification, + description: decoded_factory['schema']&.dig('description').to_s + ) + ) + else + @failure.new( + { resource: "#{classification}/#{version}/#{id}", message: 'Requested Policy Factory is not available' }, + status: :bad_request + ) + end + end + end + end +end diff --git a/app/domain/factories/Readme.md b/app/domain/factories/Readme.md new file mode 100644 index 0000000000..a5a95b519c --- /dev/null +++ b/app/domain/factories/Readme.md @@ -0,0 +1,555 @@ +# Policy Factory + +## Setup + +Setup will follow the following workflow: + +![Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## Factory Upgrade + +Upgrades will follow the following workflow: + +![Factory Upgrade](./images/factory-upgrade.png) + +```plantuml +@startuml factory-upgrade +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy with new factory versions; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## View all Policy Factories + +A role is limited to viewing the Factories they have permission (`execute`) to see. +If a role can see a factory, they will be able to see errors in mis-configured Factories. + +![Factory List Request](./images/factory-list-request.png) + +```plantuml +@startuml factory-list-request +start +:Identify target Factory based on request params; +:Gather factories the role is able to view; +partition "For each Factory Version" { + repeat + if ("Factory is present?") then (yes) + if ("Is Factory format is valid?") then (yes) + if ("Is Factory Schema is valid?") then (yes) + :Display Factory details and Schema; + else + #pink:[Error] Invalid Factory Schema; + endif + else + #pink:[Error] Invalid Factory Format; + endif + else + #pink:[Error] Factory not Defined; + endif + backward: Next Factory; + repeat while (More Factories?) +} +:Return JSON Summary; +@enduml +``` + +## Policy Factory Info Requests + +![Factory Info Request](./images/factory-info-request.png) + +```plantuml +@startuml factory-info-request +(*) --> "Identify target Factory based on request params" +if "Does Factory exist?" then + --> [yes] if "Role has permission to view factory" then + --> [yes] if "Factory is present?" then + --> "Load Factory" + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Return Schema" + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + endif + else + --> [no] "[Error] Factory not Available" + endif +else + --> [no] "[Error] Factory not Found" +endif +@enduml + +``` + +## Policy Factory Creation Requests + +![Factory Create Request](./images/factory-create-request.png) + +```plantuml +@startuml factory-create-request +(*) --> "Identify Factory variable based on request params" +if "Does factory variable exist?" then + --> [yes] if "Can role load factory?" then + --> [yes] "Load Factory" + --> [yes] if "Does factory variable have a value?" then + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Extract Schema from Factory Variable" + --> "Parse [POST] JSON Request body" + --> if "is JSON valid?" + --> [yes] if "Required keys present?" + --> [yes] if "Required values present?" + --> [yes] if "Policy rendered successfully?" + --> [yes] if "Policy namespace path rendered successfully?" + --> [yes] if "Policy successfully applied" + --> [yes] if "Factory has variables?" + --> [yes] if "Variables set successfully set?" + --> "Return Policy and Variable response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + --> (*) + else + --> [no] "[Error] Setting Variable(s) not Permitted" + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + endif + else + --> [no] " Policy Created Response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + endif + else + --> [no] "[Error] Policy Creation not Permitted" + ' note left + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + endif + else + --> [no] "[Error] Invalid Factory Namespace ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Invalid Factory Policy ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Missing Required Values" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Missing Required Keys" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Bad Request Body" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + endif + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + endif + else + --> [no] "[Error] Factory not Available" + ' note left + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + endif +else + --> [no] "[Error] Factory not Found" + ' note left + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note +endif +@enduml +``` + +## Policy Factory Creation Requests (beta) + +![Policy Factory Create Request](./images/Readme-5.png) + +```plantuml +@startuml +start +:Identify Factory\nvariable based\non request params; +if (Does factory variable exist?) then (yes) + if (Can role load factory variable?) then (yes) + if (Does factory variable have a value?) then (yes) + :Load Factory; + :Extract Schema from Factory Variable; + :Parse [POST] JSON Request body; + ' :Extract Schema from Factory; + if (Parse JSON body?) then (yes) + if (Required keys missing?) then (no) + #pink: Missing Keys; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (required values empty?) then (no) + #pink: Missing Values; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (Policy rendered?) then (yes) + if (Policy namespace path rendered?) then (yes) + if (Policy successfully applied) then (yes) + if (Factory has variables?) then (yes) + if (Variable successfully set?) then (yes) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + end + else (no) + #pink: Setting Variable(s) not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + kill + endif + else (no) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + kill + endif + else (no) + #pink: Policy Creation not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + kill + endif + else (no) + #pink: Invalid Policy Namespace ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + kill + endif + else (no) + #pink: Invalid Policy ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + kill + endif + endif + endif + else (no) + #pink: Malformed JSON; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + kill + endif + else (no) + #pink: Factory Variable empty; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + kill + endif + else (no) + #pink: Factory not available; + ' note right + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + kill + endif +else (no) + #pink: Factory Variable not present; + ' note right + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note + kill +endif +@enduml +``` + +### UI Workflow + +![UI Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Login; +:Navigate to "Policy Factories" page; +if (Can view Factories) then (yes) + :Show Factory Groupings; + :Navigate to Factory Grouping; + :Select a Factory; + if ("Can view Factory") then (yes) + :View Factory form; + if ("Factory successfully created") then (yes) + :Redirect + else + end + else + end +else (no) + :Show empty Factories page\nwith "No Factories Available"; +end +@enduml +``` + +## Code Architecture + +![Basic Overview](./images/Basic-Sample.png) + +```plantuml +@startuml Basic Sample +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +Component(controller, "PolicyFactoryController", "Rails", "Routes requests to Business Logic and renders results") + +Component(repository, "PolicyFactoryRepository", "Ruby", "Retrieves Factories from Conjur Variables") + +Component(data_object, "DataObjects::PolicyFactory", "Ruby") + +Component(create, "CreateFromPolicyFactory", "Ruby", "Generates Conjur elements using a Policy Factory") + +Rel(repository, controller, "loads factory from") + +' Component(repository 'PolicyFactoryRepository') + +' component PolicyFactoryController +' component PolicyFactoryRepository +@enduml +``` diff --git a/app/domain/factories/create_from_policy_factory.rb b/app/domain/factories/create_from_policy_factory.rb new file mode 100644 index 0000000000..92ded89bde --- /dev/null +++ b/app/domain/factories/create_from_policy_factory.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'rest_client' +require 'json_schemer' +require 'factories/renderer' + +module Factories + class Utilities + def self.filter_input(str) + str.gsub(/[^0-9a-z\-_]/i, '') + end + end + class CreateFromPolicyFactory + def initialize(renderer: Factories::Renderer.new, http: RestClient, schema_validator: JSONSchemer, utilities: Factories::Utilities) + @renderer = renderer + @http = http + @schema_validator = schema_validator + @utilities = utilities + + # JSON and URI are defined here for visibility. They are not currently + # mocked in testing, thus, we're not setting them in the initializer. + @json = JSON + @uri = URI + + # Defined here for visibility. We shouldn't need to mock these. + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def call(factory_template:, request_body:, account:, authorization:) + validate_and_transform_request( + schema: factory_template.schema, + params: request_body + ).bind do |body_variables| + # Convert `dashed` keys to `underscored`. This only occurs for top-level parameters. + # Conjur variables should be use dashes rather than underscores. + # Filter non-alpha-numeric, dash or underscore characters from inputs values (to prevent injection attacks). + template_variables = body_variables + .transform_keys { |key| key.to_s.underscore } + .each_with_object({}) do |(key, value), rtn| + # Only strip values that are rendered in the policy (not Conjur secret values) + rtn[key] = if key == 'variables' + value + elsif value.is_a?(Hash) + value.transform_values { |internal_value| @utilities.filter_input(internal_value.to_s) } + else + @utilities.filter_input(value.to_s) + end + end + + # Add empty `annotations` hash unless they've previously been set + template_variables["annotations"] = {} unless template_variables.include?('annotations') + + # Push rendered policy to the desired policy branch + @renderer.render(template: factory_template.policy_branch, variables: template_variables) + .bind do |policy_load_path| + valid_variables = factory_template.schema['properties'].keys - ['variables'] + render_and_apply_policy( + policy_load_path: policy_load_path, + policy_template: factory_template.policy, + variables: template_variables.select { |k, _| valid_variables.include?(k) }, + account: account, + authorization: authorization + ).bind do |result| + return @success.new(result) unless factory_template.schema['properties'].key?('variables') + + # Set Policy Factory variables + @renderer.render(template: "#{factory_template.policy_branch}/<%= id %>", variables: template_variables) + .bind do |variable_path| + set_factory_variables( + schema_variables: factory_template.schema['properties']['variables']['properties'], + factory_variables: template_variables['variables'], + variable_path: variable_path, + authorization: authorization, + account: account + ) + end + .bind do + # If variables were added successfully, return the result so that + # we send the policy load response back to the client. + @success.new(result) + end + end + end + end + end + + private + + def validate_and_transform_request(schema:, params:) + return @failure.new('Request body must be JSON', status: :bad_request) if params.blank? + + begin + params = @json.parse(params) + rescue + return @failure.new('Request body must be valid JSON', status: :bad_request) + end + + # Strip keys without values + params = params.select{|_, v| v.present? } + validator = @schema_validator.schema(schema) + return @success.new(params) if validator.valid?(params) + + errors = validator.validate(params).map do |error| + case error['type'] + when 'required' + missing_attributes = error['details']['missing_keys'].map{|key| [ error['data_pointer'], key].reject(&:empty?).join('/') } #.join("', '") + missing_attributes.map do |attribute| + { + message: "A value is required for '#{attribute}'", + key: attribute + } + end + else + { + message: "Validation error: '#{error['data_pointer']}' must be a #{error['type']}" + } + end + end + @failure.new(errors.flatten, status: :bad_request) + end + + def render_and_apply_policy(policy_load_path:, policy_template:, variables:, account:, authorization:) + @renderer.render( + template: policy_template, + variables: variables + ).bind do |rendered_policy| + begin + response = @http.post( + "http://localhost:3000/policies/#{account}/policy/#{policy_load_path}", + rendered_policy, + 'Authorization' => authorization + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new( + { message: 'Authentication failed', + request_error: e.response.body }, status: :unauthorized + ) + when 403 + return @failure.new( + { message: "Applying generated policy to '#{policy_load_path}' is not allowed", + request_error: e.response.body }, status: :forbidden + ) + when 404 + return @failure.new( + { message: "Unable to apply generated policy to '#{policy_load_path}'", + request_error: e.response.body }, status: :not_found + ) + else + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + + @success.new(response.body) + end + end + + def set_factory_variables(schema_variables:, factory_variables:, variable_path:, authorization:, account:) + # Only set secrets defined in the policy + schema_variables.each_key do |factory_variable| + variable_id = @uri.encode_www_form_component("#{variable_path}/#{factory_variable}") + secret_path = "secrets/#{account}/variable/#{variable_id}" + + @http.post( + "http://localhost:3000/#{secret_path}", + factory_variables[factory_variable].to_s, + { 'Authorization' => authorization } + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new("Role is unauthorized to set variable: '#{secret_path}'", status: :unauthorized) + when 403 + return @failure.new("Role lacks the privilege to set variable: '#{secret_path}'", status: :forbidden) + else + return @failure.new( + "Failed to set variable: '#{secret_path}'. Status Code: '#{e.response.code}', Response: '#{e.response.body}'", + status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed set variable '#{secret_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + @success.new('Variables successfully set') + end + end +end diff --git a/app/domain/factories/images/Basic-Sample.png b/app/domain/factories/images/Basic-Sample.png new file mode 100644 index 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/presenters/policy_factories/error.rb b/app/presenters/policy_factories/error.rb new file mode 100644 index 0000000000..ed84a45ca0 --- /dev/null +++ b/app/presenters/policy_factories/error.rb @@ -0,0 +1,28 @@ +module Presenter + module PolicyFactories + # Returns a Hash representation of an Failure Response to be used by the controller + class Error + # Response is always a FailureResponse + def initialize(response:, response_codes: HTTP::Response::Status::SYMBOL_CODES) + @response = response + @response_codes = response_codes + end + + def present + { + code: @response_codes[@response.status] + }.tap do |rtn| + rtn[:error] = format_error_message(@response.message) + end + end + + private + + def format_error_message(message) + return message if message.is_a?(Array) || message.is_a?(Hash) + + { message: message.to_s } + end + end + end +end diff --git a/app/presenters/policy_factories/index.rb b/app/presenters/policy_factories/index.rb new file mode 100644 index 0000000000..36fea65755 --- /dev/null +++ b/app/presenters/policy_factories/index.rb @@ -0,0 +1,35 @@ +module Presenter + module PolicyFactories + # returns a Hash representation to be used by the controller + class Index + def initialize(factories:) + @factories = factories + end + + def present + {}.tap do |rtn| + @factories + .group_by(&:classification) + .sort_by {|classification, _| classification } + .map do |classification, factories| + rtn[classification] = factories + .map { |factory| factory_to_hash(factory) } + .sort { |x, y| x[:name] <=> y[:name] } + end + end + end + + private + + def factory_to_hash(factory) + { + name: factory.name, + namespace: factory.classification, + 'full-name': "#{factory.classification}/#{factory.name}", + 'current-version': factory.version, + description: factory.description || '' + } + end + end + end +end diff --git a/app/presenters/policy_factories/show.rb b/app/presenters/policy_factories/show.rb new file mode 100644 index 0000000000..1bfdcd2f00 --- /dev/null +++ b/app/presenters/policy_factories/show.rb @@ -0,0 +1,20 @@ +module Presenter + module PolicyFactories + # returns a hash representation to be used by the controller + class Show + def initialize(factory:) + @factory = factory + end + + def present + { + title: @factory.schema['title'], + version: @factory.version, + description: @factory.schema['description'], + properties: @factory.schema['properties'], + required: @factory.schema['required'] + } + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 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/lib/tasks/policy_factory.rake b/lib/tasks/policy_factory.rake new file mode 100644 index 0000000000..a40b266e28 --- /dev/null +++ b/lib/tasks/policy_factory.rake @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Factories + module Templates + class ValidateTemplate + def initialize(renderer: Factories::RenderPolicy.new) + @renderer = renderer + end + + def test(factory:, template_params:) + puts('template:') + puts(factory.policy_template) + puts('--------------') + puts('') + puts('rendered template:') + puts(@renderer.render(policy_template: factory.policy_template, variables: template_params)) + + # puts(ERB.new(@factory.policy_template, nil, '-').result_with_hash(args)) + end + end + end +end + +namespace :policy_factory do + def api_key + return ENV['CONJUR_AUTHN_API_KEY'] if ENV.key?('CONJUR_AUTHN_API_KEY') + + raise 'Conjur `admin` user API key must be provided via `CONJUR_AUTHN_API_KEY` environment variable' + end + + def client + @client ||= begin + Conjur.configuration.account = 'cucumber' + Conjur.configuration.appliance_url = 'http://localhost:3000/' + Conjur::API.new_from_key('admin', api_key) + end + end + + task test: :environment do + binding.pry + # tester = Factories::Templates::ValidateTemplate.new + # tester.test( + # factory: Factories::Templates::Core::Group, + # template_params: { "id"=>"test-group", "branch"=>"root", "annotations"=>{ "one"=>1, "two"=>2, "test/three"=>3 } } + # ) + # tester.test( + # factory: Factories::Templates::Core::Group, + # template_params: { "id"=>"test-group", "branch"=>"root" } + # ) + end + + task load: :environment do + binding.pry + client.load_policy('root', Factories::Templates::Base::V1::BasePolicy.policy) + client.resource('cucumber:variable:conjur/factories/core/v1/group').add_value(Factories::Templates::Core::V1::Group.data) + client.resource('cucumber:variable:conjur/factories/core/v1/managed-policy').add_value(Factories::Templates::Core::V1::ManagedPolicy.data) + client.resource('cucumber:variable:conjur/factories/core/v1/policy').add_value(Factories::Templates::Core::V1::Policy.data) + client.resource('cucumber:variable:conjur/factories/core/v1/user').add_value(Factories::Templates::Core::V1::User.data) + client.resource('cucumber:variable:conjur/factories/authenticators/v1/authn-oidc').add_value(Factories::Templates::Authenticators::V1::AuthnOidc.data) + client.resource('cucumber:variable:conjur/factories/connections/v1/database').add_value(Factories::Templates::Connections::V1::Database.data) + end + + task retrieve_auth_token: :environment do + url = 'http://localhost:3000/' + username = 'admin' + + response = RestClient.post("#{url}/authn/cucumber/#{username}/authenticate", api_key, 'Accept-Encoding' => 'base64') + puts response.body + end +end diff --git a/spec/app/db/repository/policy_factory_repository_spec.rb b/spec/app/db/repository/policy_factory_repository_spec.rb new file mode 100644 index 0000000000..df89cf4be5 --- /dev/null +++ b/spec/app/db/repository/policy_factory_repository_spec.rb @@ -0,0 +1,421 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(DB::Repository::PolicyFactoryRepository) do + # Ensure the variables below have not been set in previous tests + before(:all) do + ::Resource['rspec:variable:conjur/factories/core/v1/group']&.destroy + ::Resource['rspec:variable:conjur/factories/core/v1/user']&.destroy + end + subject { DB::Repository::PolicyFactoryRepository.new } + + describe 'find_all' do + context 'when no factories exist' do + before(:each) do + ::Role.create(role_id: 'rspec:group:conjur/policy-factory-users') + end + after(:each) do + ::Role['rspec:group:conjur/policy-factory-users'].destroy + end + it 'returns an error' do + response = subject.find_all( + account: 'foo-bar', + role: ::Role['rspec:group:conjur/policy-factory-users'] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to eq('Role does not have permission to use Factories') + end + end + + context 'when factories exist' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:owner_id) { role_id } + let(:factory1) { 'rspec:variable:conjur/factories/core/v1/group' } + let(:factory2) { 'rspec:variable:conjur/factories/core/v1/user' } + + before(:each) do + ::Role.create(role_id: role_id) + end + after(:each) do + ::Role[role_id].destroy + end + + context 'when role does not have execute permission on any factories' do + let(:owner_id) { 'rspec:group:admin' } + before(:each) do + ::Role.create(role_id: owner_id) + ::Resource.create(resource_id: factory1, owner_id: owner_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: owner_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + ::Role[owner_id].destroy + end + it 'returns an error' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to eq('Role does not have permission to use Factories') + end + end + context 'when role has execute permission on some factories' do + let(:owner_id) { 'rspec:group:admin' } + before(:each) do + ::Role.create(role_id: owner_id) + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: owner_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + ::Role[owner_id].destroy + end + it 'returns permitted factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.name).to eq('group') + expect(response.result.first.description).to eq('Creates a Conjur Group') + end + end + context 'when role has execute permission on all factories' do + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: role_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'returns all factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(2) + expect(response.result.map(&:name)).to include('group') + expect(response.result.map(&:name)).to include('user') + end + end + context 'when multiple versions of a factory exist' do + let(:factory1) { 'rspec:variable:conjur/factories/core/v1/group' } + let(:factory2) { 'rspec:variable:conjur/factories/core/v2/group' } + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Secret.create( + resource_id: factory1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: factory2, owner_id: role_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::Group.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'returns the latest version' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.version).to eq('v2') + end + context 'when there are more than 10 factory versions' do + let(:factory1) { 'rspec:variable:conjur/factories/core/v9/group' } + let(:factory2) { 'rspec:variable:conjur/factories/core/v10/group' } + it 'returns the latest version' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.version).to eq('v10') + end + end + end + context 'when some factories are empty' do + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Resource.create(resource_id: factory2, owner_id: role_id) + ::Secret.create( + resource_id: factory2, + value: Factories::Templates::Core::V1::User.data + ) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'does not return empty factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.count).to eq(1) + expect(response.result.first.name).to eq('user') + end + end + context 'when all factories are empty' do + # TODO: this error is a bit weird... I'd expect a specific error if no factories were configured. + before(:each) do + ::Resource.create(resource_id: factory1, owner_id: role_id) + ::Resource.create(resource_id: factory2, owner_id: role_id) + end + after(:each) do + ::Resource[factory1].destroy + ::Resource[factory2].destroy + end + it 'does not return any factories' do + response = subject.find_all( + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to eq('Role does not have permission to use Factories') + end + end + end + end + + describe '.find' do + context 'when factory does not exist' do + before(:each) do + ::Role.create(role_id: 'rspec:group:conjur/policy-factory-users') + end + after(:each) do + ::Role['rspec:group:conjur/policy-factory-users'].destroy + end + it 'returns an error' do + response = subject.find( + kind: 'foo', + id: 'bar', + account: 'foo-bar', + role: ::Role['rspec:group:conjur/policy-factory-users'] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:not_found) + expect(response.message).to include( + { + resource: 'foo/v1/bar', + message: 'Requested Policy Factory does not exist' + } + ) + end + end + context 'when factory exists' do + context 'when requesting role does not have permission' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:resource_id) { 'rspec:variable:conjur/factories/core/v1/group' } + before(:each) do + ::Role.create(role_id: role_id) + admin = ::Role.create(role_id: 'rspec:user:policy_admin') + ::Resource.create(resource_id: resource_id, owner: admin) + end + after(:each) do + ::Role[role_id].destroy + ::Resource[resource_id].destroy + ::Role['rspec:user:policy_admin'].destroy + end + it 'returns an error' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:forbidden) + expect(response.message).to include( + { + resource: 'core/v1/group', + message: 'Requested Policy Factory is not available' + } + ) + end + end + context 'when factory is empty' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:resource_id) { 'rspec:variable:conjur/factories/core/v1/group' } + before(:each) do + ::Role.create(role_id: role_id) + ::Resource.create(resource_id: resource_id, owner_id: role_id) + end + after(:each) do + ::Resource[resource_id].destroy + ::Role[role_id].destroy + end + it 'returns an error' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(false) + expect(response.status).to eq(:bad_request) + expect(response.message).to include( + { + resource: 'core/v1/group', + message: 'Requested Policy Factory is not available' + } + ) + end + end + context 'requesting role has permission' do + let(:role_id) { 'rspec:group:conjur/policy-factory-users' } + let(:resource_id) { 'rspec:variable:conjur/factories/core/v1/group' } + before(:each) do + ::Role.create(role_id: role_id) + ::Resource.create(resource_id: resource_id, owner_id: role_id) + ::Secret.create( + resource_id: resource_id, + value: Factories::Templates::Core::V1::Group.data + ) + end + after(:each) do + ::Resource[resource_id].destroy + ::Role[role_id].destroy + end + it 'returns the policy factory' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.class).to eq(DB::Repository::DataObjects::PolicyFactory) + expect(response.result.name).to eq('group') + end + context 'when description attribute is missing' do + before(:each) do + data = Factories::Templates::Core::V1::Group.data + decoded_data = JSON.parse(Base64.decode64(data)) + decoded_data['schema'].delete('description') + + ::Secret.create( + resource_id: resource_id, + value: Base64.encode64(decoded_data.to_json) + ) + end + it 'includes an empty description' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[role_id] + ) + expect(response.success?).to eq(true) + expect(response.result.description).to eq('') + end + end + end + context 'when multiple versions exist' do + let(:owner_id) { 'rspec:group:conjur/policy-factory-users' } + let(:version1) { 'rspec:variable:conjur/factories/core/v1/group' } + let(:version2) { 'rspec:variable:conjur/factories/core/v2/group' } + before(:each) do + ::Role.create(role_id: owner_id) + ::Resource.create(resource_id: version1, owner_id: owner_id) + ::Secret.create( + resource_id: version1, + value: Factories::Templates::Core::V1::Group.data + ) + ::Resource.create(resource_id: version2, owner_id: owner_id) + ::Secret.create( + resource_id: version2, + value: Factories::Templates::Core::V1::Group.data + ) + end + after(:each) do + ::Resource[version1].destroy + ::Resource[version2].destroy + ::Role[owner_id].destroy + end + context 'when no version is provided' do + it 'returns the latest version' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[owner_id] + ) + expect(response.success?).to eq(true) + expect(response.result.version).to eq('v2') + end + end + context 'when a version is provided' do + it 'returns the requested version' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[owner_id], + version: 'v1' + ) + expect(response.success?).to eq(true) + expect(response.result.version).to eq('v1') + end + end + + context 'when there are more than 10 factory versions' do + let(:version1) { 'rspec:variable:conjur/factories/core/v9/group' } + let(:version2) { 'rspec:variable:conjur/factories/core/v10/group' } + it 'returns the latest version' do + response = subject.find( + kind: 'core', + id: 'group', + account: 'rspec', + role: ::Role[owner_id] + ) + expect(response.success?).to eq(true) + expect(response.result.version).to eq('v10') + end + end + + end + end + end +end diff --git a/spec/app/domain/factories/create_from_policy_factory_spec.rb b/spec/app/domain/factories/create_from_policy_factory_spec.rb new file mode 100644 index 0000000000..f2ac6bd029 --- /dev/null +++ b/spec/app/domain/factories/create_from_policy_factory_spec.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Factories::CreateFromPolicyFactory) do + let(:rest_client) { spy(RestClient) } + subject do + Factories::CreateFromPolicyFactory + .new(http: rest_client) + .call( + factory_template: factory_template, + request_body: request, + account: 'rspec', + authorization: 'foo-bar' + ) + end + + describe('.call') do + context 'when using a simple factory' do + let(:factory_template) do + decoded_factory = JSON.parse(Base64.decode64(Factories::Templates::Core::V1::User.data)) + DB::Repository::DataObjects::PolicyFactory.new( + schema: decoded_factory['schema'], + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'] + ) + end + context 'when request is invalid' do + context 'when request body is missing' do + let(:request) { nil } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq('Request body must be JSON') + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is empty' do + let(:request) { '' } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq('Request body must be JSON') + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is malformed JSON' do + let(:request) { '{"foo": "bar }' } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq('Request body must be valid JSON') + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is missing keys' do + let(:request) { { id: 'foo' }.to_json } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "A value is required for 'branch'", key: 'branch' }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when request body is missing values' do + let(:request) { { id: '', branch: 'foo' }.to_json } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "A value is required for 'id'", key: 'id' }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when the request body includes invalid values' do + let(:request) { { id: 'foo%', branch: 'b@r' }.to_json } + it 'submits the expected policy to Conjur with invalid characters removed' do + expect(subject.success?).to be(true) + expect(rest_client).to have_received(:post).with('http://localhost:3000/policies/rspec/policy/br', "- !user\n id: foo\n annotations:\n factory: core/v1/user\n", { 'Authorization' => 'foo-bar' }) + end + end + context 'when request body is valid' do + let(:request) { { id: 'foo', branch: 'bar' }.to_json } + it 'submits the expected policy to Conjur' do + expect(subject.success?).to be(true) + expect(rest_client).to have_received(:post).with('http://localhost:3000/policies/rspec/policy/bar', "- !user\n id: foo\n annotations:\n factory: core/v1/user\n", { 'Authorization' => 'foo-bar' }) + end + context 'when inputs include a hash (ex. for annotations)' do + let(:request) { { id: 'foo', branch: 'bar', annotations: { 'foo' => 'bar', 'bing' => 'bang' } }.to_json } + it 'submits the expected policy to Conjur' do + expect(subject.success?).to be(true) + expect(rest_client).to have_received(:post).with('http://localhost:3000/policies/rspec/policy/bar', "- !user\n id: foo\n annotations:\n factory: core/v1/user\n foo: bar\n bing: bang\n", { 'Authorization' => 'foo-bar' }) + end + end + context 'when the Conjur API returns an error' do + context 'when credentials are invalid' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 401, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq('Authentication failed') + expect(subject.status).to eq(:unauthorized) + end + end + context 'when role is not permitted to apply the policy' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 403, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to include({ + message: "Applying generated policy to 'bar' is not allowed", + request_error: 'foo' + }) + expect(subject.status).to eq(:forbidden) + end + end + context 'when policy refers to invalid roles or resources' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 404, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq("Unable to apply generated policy to 'bar'") + expect(subject.status).to eq(:not_found) + end + end + context 'when policy load is currently in progress' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 409, body: 'foo') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq("Failed to apply generated policy to 'bar'") + expect(subject.status).to eq(:bad_request) + end + end + context 'when a connection timeout error occurs' do + it 'returns a failure response' do + allow(rest_client).to receive(:post).and_raise( + RestClient::ServerBrokeConnection.new + ) + + expect(subject.success?).to be(false) + expect(subject.message[:message]).to eq("Failed to apply generated policy to 'bar'") + expect(subject.status).to eq(:bad_request) + end + end + end + end + end + end + context 'when using a complex factory' do + let(:factory_template) do + decoded_factory = JSON.parse(Base64.decode64(Factories::Templates::Connections::V1::Database.data)) + DB::Repository::DataObjects::PolicyFactory.new( + schema: decoded_factory['schema'], + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'] + ) + end + let(:request) { { id: 'bar', branch: 'foo', variables: variables }.to_json } + context 'when request body is missing values' do + let(:variables) { { port: '1234', url: 'http://localhost', username: 'super-user' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "A value is required for '/variables/password'", key: '/variables/password' }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when variable value is not a string' do + context 'when value is an integer' do + let(:variables) { { port: 1234, url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "Validation error: '/variables/port' must be a string" }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when value is a boolean' do + let(:variables) { { port: true, url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "Validation error: '/variables/port' must be a string" }]) + expect(subject.status).to eq(:bad_request) + end + end + context 'when value is null' do + let(:variables) { { port: nil, url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'returns a failure response' do + expect(subject.success?).to be(false) + expect(subject.message).to eq([{ message: "Validation error: '/variables/port' must be a string" }]) + expect(subject.status).to eq(:bad_request) + end + end + end + context 'when request body includes required values' do + let(:variables) { { port: '1234', url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + variables.each do |variable, value| + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2F#{variable}", + value, + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '') + ) + end + expect(subject.success?).to be(true) + end + end + context 'when request body includes extra variable values' do + let(:variables) { { foo: 'bar', port: '1234', url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + # let(:request) { { id: 'bar', branch: 'foo', variables: variables }.to_json } + it 'only saves variables defined in the factory' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + variables.delete(:foo) + variables.each do |variable, value| + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2F#{variable}", + value, + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '') + ) + end + expect(subject.success?).to be(true) + end + end + context 'when role is not permitted to set variables' do + let(:variables) { { port: '1234', url: 'http://localhost', username: 'super-user', password: 'foo-bar' } } + context 'when role is not authorized' do + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 401, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Role is unauthorized to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'") + expect(subject.status).to eq(:unauthorized) + end + end + context 'when role lacks required privileges' do + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 403, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Role lacks the privilege to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'") + expect(subject.status).to eq(:forbidden) + end + end + context 'when variable is missing' do + it 'fails with an appropriate error' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 404, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Failed to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'. Status Code: '404', Response: ''") + expect(subject.status).to eq(:bad_request) + end + end + context 'when there is a variable missing' do + it 'applies policy and variables' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::BadRequest.new( + double(RestClient::Response, code: 404, body: '') + ) + ) + + expect(subject.success?).to be(false) + expect(subject.message).to eq("Failed to set variable: 'secrets/rspec/variable/foo%2Fbar%2Furl'. Status Code: '404', Response: ''") + expect(subject.status).to eq(:bad_request) + end + end + context 'when there is a timeout attempting to set the secret' do + it 'returns the appropriate error' do + allow(rest_client).to receive(:post) + .with( + 'http://localhost:3000/policies/rspec/policy/foo', + "- !policy\n id: bar\n annotations:\n factory: connections/v1/database\n \n body:\n - &variables\n - !variable url\n - !variable port\n - !variable username\n - !variable password\n\n - !group consumers\n - !group administrators\n\n # consumers can read and execute\n - !permit\n resource: *variables\n privileges: [ read, execute ]\n role: !group consumers\n\n # administrators can update (and read and execute, via role grant)\n - !permit\n resource: *variables\n privileges: [ update ]\n role: !group administrators\n\n # administrators has role consumers\n - !grant\n member: !group administrators\n role: !group consumers\n", + { 'Authorization' => 'foo-bar' } + ).and_return( + double(RestClient::Response, code: 201, body: '{"created_roles":{},"version":13}') + ) + + allow(rest_client).to receive(:post) + .with( + "http://localhost:3000/secrets/rspec/variable/foo%2Fbar%2Furl", + 'http://localhost', + { 'Authorization' => 'foo-bar' } + ).and_raise( + RestClient::ServerBrokeConnection.new + ) + + expect(subject.success?).to be(false) + expect(subject.message).to include({ + message: "Failed set variable 'secrets/rspec/variable/foo%2Fbar%2Furl'", + request_error: 'Server broke connection' + }) + expect(subject.status).to eq(:bad_request) + end + end + end + end + end +end diff --git a/spec/app/domain/factories/renderer_spec.rb b/spec/app/domain/factories/renderer_spec.rb new file mode 100644 index 0000000000..810f1d906b --- /dev/null +++ b/spec/app/domain/factories/renderer_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Factories::Renderer) do + subject { Factories::Renderer.new } + describe '.render' do + context 'when template is valid' do + let(:template) do + <<~TEMPLATE + - !policy + id: <%= id %> + TEMPLATE + end + context 'when all variables are present' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: 'foo' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: foo\n") + end + end + context 'when variables are missing' do + it 'returns an error' do + response = subject.render(template: template, variables: {}) + expect(response.success?).to be_falsey + expect(response.message).to eq("Required template variable 'id' is missing") + end + end + context 'when variable is nil' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: nil }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: \n") + end + end + context 'when extra variables are present' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: 'foo', bar: 'baz' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: foo\n") + end + end + context 'when variables are not strings' do + context 'when variable is an integer' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: 1 }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: 1\n") + end + end + context 'when variable is a boolean' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: false }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: false\n") + end + end + context 'when variable is an array' do + it 'successfully renders template' do + response = subject.render(template: template, variables: { id: %w[foo bar] }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: [\"foo\", \"bar\"]\n") + end + end + end + end + context 'when template is invalid' do + context 'when there is not ERB closing tag' do + let(:template) do + <<~TEMPLATE + - !policy + id: <%= id + bar: baz + TEMPLATE + end + it 'the result is successful, does not perform substitution, and does not include the opening tag' do + response = subject.render(template: template, variables: { id: 'foo' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: id\n bar: baz\n") + end + end + context 'when the template is missing an ERB opening tag' do + let(:template) do + <<~TEMPLATE + - !policy + id: id %> + bar: baz + TEMPLATE + end + it 'the result is successful, does not perform substitution, and includes the closing tag' do + response = subject.render(template: template, variables: { id: 'foo' }) + expect(response.success?).to be_truthy + expect(response.result).to eq("- !policy\n id: id %>\n bar: baz\n") + end + end + end + end +end diff --git a/spec/app/domain/responses_spec.rb b/spec/app/domain/responses_spec.rb new file mode 100644 index 0000000000..f3ad1b35db --- /dev/null +++ b/spec/app/domain/responses_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SuccessResponse do + context 'when initialized' do + let(:success) { SuccessResponse.new('foo') } + + describe '.result' do + it 'is the message set in the initializer' do + expect(success.result).to eq('foo') + end + end + + describe '.success?' do + it 'is true' do + expect(success.success?).to be(true) + end + end + + describe '.bind' do + it 'binds this response message to the next operation' do + expect(success.bind { |response| "#{response}-bar"}).to eq('foo-bar') + end + end + end +end + +describe FailureResponse do + context 'when initialized with only a message' do + let(:failure) { FailureResponse.new('bar') } + + describe '.message' do + it 'is the message set in the initializer' do + expect(failure.message).to eq('bar') + end + end + + describe '.level' do + it 'is at `warn` level by default' do + expect(failure.level).to eq(:warn) + end + end + + describe '.success?' do + it 'is false' do + expect(failure.success?).to be(false) + end + end + + describe '.bind' do + it "doesn't bind the response message to the next operation" do + expect(failure.bind { |response| "foo-#{response}"}).to eq(failure) + end + end + end + + context 'when initialized with all options' do + let(:message) { 'baz' } + let(:initialize_arguments) { { level: :debug, status: :forbidden } } + let(:failure) { FailureResponse.new(message, **initialize_arguments) } + + describe '.message' do + context 'when message is set in the initializer' do + context 'when it is a string' do + it "is returned as a string" do + expect(failure.message).to eq('baz') + end + end + context 'when it is a hash' do + let(:message) { { foo: 'baz' } } + it 'is returned as a hash' do + expect(failure.message).to eq({ foo: 'baz' }) + end + end + context 'when it is an array' do + let(:message) { [{ foo: 'baz' }] } + it 'is returned as an array' do + expect(failure.message).to eq([{ foo: 'baz' }]) + end + end + end + end + + describe '.to_s' do + context 'when message is a string' do + let(:message) { 'baz' } + it 'returns the expected string' do + expect(failure.to_s).to eq('baz') + end + end + context 'when message is a hash' do + let(:message) { { foo: 'baz' } } + it 'returns the expected string' do + expect(failure.to_s).to eq('{:foo=>"baz"}') + end + end + context 'when message is an array' do + let(:message) { ['baz'] } + it 'returns the expected string' do + expect(failure.to_s).to eq('["baz"]') + end + end + end + + describe '.level' do + context 'when level is a symbol' do + let(:initialize_arguments) { { level: :warn, status: :forbidden } } + it 'is the level set in the initializer' do + expect(failure.level).to eq(:warn) + end + end + + context 'when level is a string' do + let(:initialize_arguments) { { level: 'warn', status: :forbidden } } + it 'is the level set in the initializer' do + expect(failure.level).to eq(:warn) + end + end + end + + describe '.status' do + context 'when set in initializer' do + it 'is the message set in the initializer' do + expect(failure.status).to eq(:forbidden) + end + end + context 'when set by default' do + let(:initialize_arguments) { {} } + it 'is the default option' do + expect(failure.status).to eq(:unauthorized) + end + end + end + + describe '.success?' do + it 'is false' do + expect(failure.success?).to be(false) + end + end + end +end diff --git a/spec/app/presenters/policy_factories/error_spec.rb b/spec/app/presenters/policy_factories/error_spec.rb new file mode 100644 index 0000000000..8c4f4a27b5 --- /dev/null +++ b/spec/app/presenters/policy_factories/error_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Presenter::PolicyFactories::Error) do + subject do + Presenter::PolicyFactories::Error.new(response: response) + end + + describe '.present' do + context 'when response message is a string' do + let(:response) { FailureResponse.new('foo-bar') } + it 'returns the expected value' do + expect(subject.present).to eq({ + code: 401, + error: { message: 'foo-bar' } + }) + end + end + context 'when response message is a hash' do + let(:response) { FailureResponse.new({ message: 'foo-bar' }) } + it 'returns the expected value' do + expect(subject.present).to eq({ + code: 401, + error: { message: 'foo-bar' } + }) + end + end + context 'when response message is an array' do + let(:response) { FailureResponse.new([{ message: 'foo-bar' }]) } + it 'returns the expected value' do + expect(subject.present).to eq({ + code: 401, + error: [{ message: 'foo-bar' }] + }) + end + end + + end +end diff --git a/spec/app/presenters/policy_factories/index_spec.rb b/spec/app/presenters/policy_factories/index_spec.rb new file mode 100644 index 0000000000..32e1e23d60 --- /dev/null +++ b/spec/app/presenters/policy_factories/index_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Presenter::PolicyFactories::Index) do + describe '.present' do + subject do + Presenter::PolicyFactories::Index.new( + factories: [ + DB::Repository::DataObjects::PolicyFactory.new( + name: 'foo1', + classification: 'foo', + version: 'v1', + description: 'This is foo' + ), + DB::Repository::DataObjects::PolicyFactory.new( + name: 'bar1', + classification: 'foo', + version: 'v1' + ) + ] + ) + end + + it 'returns the expected hash' do + expect(subject.present).to include( + { + "foo" => [ + { + name: 'bar1', + namespace: 'foo', + 'full-name': 'foo/bar1', + 'current-version': 'v1', + description: '' + }, { + name: 'foo1', + namespace: 'foo', + 'full-name': 'foo/foo1', + 'current-version': 'v1', + description: 'This is foo' + } + ] + } + ) + end + end +end diff --git a/spec/app/presenters/policy_factories/show_spec.rb b/spec/app/presenters/policy_factories/show_spec.rb new file mode 100644 index 0000000000..ca93784abe --- /dev/null +++ b/spec/app/presenters/policy_factories/show_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Presenter::PolicyFactories::Show) do + describe '.present' do + subject { Presenter::PolicyFactories::Show.new(factory: factory) } + context 'when factory is composed of string keys' do + let(:factory) do + DB::Repository::DataObjects::PolicyFactory.new( + schema: { + 'title' => 'foo-bar', + 'description' => 'some factory', + 'properties' => { + 'id' => { + 'description' => 'Group ID', + 'type' => 'string' + }, + 'branch' => { + 'description' => 'Policy branch to load this group into', + 'type' => 'string' + }, + 'annotations' => { + 'description' => 'Additional annotations to add to the group', + 'type' => 'object' + } + }, + 'required' => %w[id branch] + }, + version: 'v1' + ) + end + + it 'returns the expected hash' do + expect(subject.present).to include( + { + title: 'foo-bar', + version: 'v1', + description: 'some factory', + properties: { + 'annotations' => { + 'description' => 'Additional annotations to add to the group', + 'type' => 'object' + }, + 'branch' => { + 'description' => 'Policy branch to load this group into', + 'type' => 'string' + }, + 'id' => { + 'description' => 'Group ID', + 'type' => 'string' + } + }, + required: %w[id branch] + } + ) + end + end + end +end diff --git a/spec/controllers/policy_factories_controller_spec.rb b/spec/controllers/policy_factories_controller_spec.rb new file mode 100644 index 0000000000..4768235930 --- /dev/null +++ b/spec/controllers/policy_factories_controller_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require 'spec_helper' + +DatabaseCleaner.strategy = :truncation + +describe PolicyFactoriesController, type: :request do + before(:all) do + Slosilo['authn:rspec'] ||= Slosilo::Key.new + + admin_user = Role.find_or_create(role_id: 'rspec:user:admin') + post( + '/policies/rspec/policy/root', + env: token_auth_header(role: admin_user).merge({ 'RAW_POST_DATA' => Factories::Templates::Base::V1::BasePolicy.policy }) + ) + { + 'core/v1/group' => Factories::Templates::Core::V1::Group.data, + 'core/v1/user' => Factories::Templates::Core::V1::User.data + }.each do |factory, data| + post( + "/secrets/rspec/variable/conjur/factories/#{factory}", + env: token_auth_header(role: admin_user).merge({ 'RAW_POST_DATA' => data }) + ) + end + end + + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } + + describe '#index' do + context 'when user has permission' do + context 'it shows available factories' do + it 'displays expected values' do + get( + '/factories/rspec', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('200') + expect(result['core'].length).to eq(2) + expect(result['core']).to include({ + 'name' => 'group', + 'namespace' => 'core', + 'full-name' => 'core/group', + 'current-version' => 'v1', + 'description' => 'Creates a Conjur Group' + }) + expect(result['core']).to include({ + 'name' => 'user', + 'namespace' => 'core', + 'full-name' => 'core/user', + 'current-version' => 'v1', + 'description' => 'Creates a Conjur User' + }) + end + end + end + context 'when role does not have permission' do + let(:current_user) { Role.find_or_create(role_id: 'rspec:user:foo-bar') } + it 'returns an appropriate error response' do + get( + '/factories/rspec', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('403') + expect(result).to eq({ + 'code' => 403, + 'error' => { + 'message' => 'Role does not have permission to use Factories' + } + }) + end + end + end + + describe '#show' do + let(:desired_result) do + { + 'title' => 'User Template', + 'version' => 'v1', + 'description' => 'Creates a Conjur User', + 'properties' => { + 'id' => { + 'description' => 'User ID', + 'type' => 'string' + }, + 'branch' => { + 'description' => 'Policy branch to load this user into', + 'type' => 'string' + }, + 'owner_role' => { + 'description' => 'The Conjur Role that will own this user', + 'type' => 'string' + }, + 'owner_type' => { + 'description' => 'The resource type of the owner of this user', + 'type' => 'string' + }, + 'ip_range' => { + 'description' => 'Limits the network range the user is allowed to authenticate from', + 'type' => 'string' + }, + 'annotations' => { + 'description' => 'Additional annotations', + 'type' => 'object' + } + }, + 'required' => %w[id branch] + } + end + context 'when role has permission to access' do + context 'when version is included in request' do + it 'returns the expected response' do + get( + '/factories/rspec/core/v1/user', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('200') + expect(result).to eq(desired_result) + end + end + context 'when version is not present in request' do + it 'returns the latest version' do + get( + '/factories/rspec/core/user', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('200') + expect(result).to eq(desired_result) + end + end + end + context 'when factory does not exist' do + it 'returns the expected response' do + get( + '/factories/rspec/core/v1/fake-factory', + env: token_auth_header(role: current_user) + ) + result = JSON.parse(response.body) + expect(response.code).to eq('404') + expect(result).to eq({ + 'code' => 404, + 'error' => { + 'message' => 'Requested Policy Factory does not exist', + 'resource' => 'core/v1/fake-factory' + } + }) + end + end + end + describe '#create' do + context 'when a factory exists' do + context 'when role has permission to create from the factory' do + let(:policy_creator) { instance_double(Factories::CreateFromPolicyFactory) } + let(:double_class) { class_double(Factories::CreateFromPolicyFactory).as_stubbed_const } + + before do + allow(double_class).to receive(:new).and_return(policy_creator) + allow(policy_creator).to receive(:call).and_return(::SuccessResponse.new('success!!')) + end + + it 'creates the desire resource' do + auth_headers = token_auth_header(role: current_user) + request_body = { + 'id': 'test-user-1', + 'branch': 'root' + }.to_json + post( + '/factories/rspec/core/user', + env: auth_headers.merge({ 'RAW_POST_DATA' => request_body }) + ) + + # We're really only checking that the Factories::CreateFromPolicyFactory.call method + # is called with expected arguements. We're testing this class separately. + decoded_factory = JSON.parse(Base64.decode64(Factories::Templates::Core::V1::User.data)) + expect(policy_creator).to have_received(:call).with({ + account: 'rspec', + factory_template: DB::Repository::DataObjects::PolicyFactory.new( + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'], + schema: decoded_factory['schema'], + version: 'v1', + name: 'user', + classification: 'core', + description: decoded_factory['schema']&.dig('description').to_s + ), + request_body: { id: 'test-user-1', branch: 'root' }.to_json, + authorization: auth_headers['HTTP_AUTHORIZATION'] + }) + expect(response.code).to eq('200') + # This response is mocked. We're not really returning this in real life. + # Tests on Factories::CreateFromPolicyFactory verify that we always receive + # a success of failure object. + expect(response.body).to eq('success!!') + end + end + end + end +end