diff --git a/.rubocop.yml b/.rubocop.yml index 29d37195..f1a81d5c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -20,3 +20,7 @@ RSpec/ExampleLength: RSpec/MultipleExpectations: Enabled: false + +RSpec/NestedGroups: + Enabled: true + Max: 5 diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb index e5b5c83b..21ebbac8 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -47,7 +47,7 @@ def generate_app_config(app) generator = TerraformConfig::Generator.new(config: config, template: template) # TODO: Delete line below after all template kinds are supported - next unless %w[gvc identity secret].include?(template["kind"]) + next unless %w[gvc identity secret policy].include?(template["kind"]) File.write(terraform_app_dir.join(generator.filename), generator.tf_config.to_tf, mode: "a+") end diff --git a/lib/core/terraform_config/generator.rb b/lib/core/terraform_config/generator.rb index a5f9f42e..4e207c0c 100644 --- a/lib/core/terraform_config/generator.rb +++ b/lib/core/terraform_config/generator.rb @@ -9,6 +9,7 @@ def initialize(config:, template:) @template = template end + # rubocop:disable Metrics/MethodLength def filename case template["kind"] when "gvc" @@ -17,22 +18,19 @@ def filename "secrets.tf" when "identity" "identities.tf" + when "policy" + "policies.tf" else raise "Unsupported template kind - #{template['kind']}" end end + # rubocop:enable Metrics/MethodLength def tf_config - case template["kind"] - when "gvc" - gvc_config - when "identity" - identity_config - when "secret" - secret_config - else - raise "Unsupported template kind - #{template['kind']}" - end + method_name = :"#{template['kind']}_config" + raise "Unsupported template kind - #{template['kind']}" unless self.class.private_method_defined?(method_name) + + send(method_name) end private @@ -61,7 +59,7 @@ def gvc_config def identity_config TerraformConfig::Identity.new( - gvc: "cpln_gvc.#{config.app}.name", # GVC name matches application name + gvc: gvc, name: template["name"], description: template["description"], tags: template["tags"] @@ -78,6 +76,38 @@ def secret_config ) end + # rubocop:disable Metrics/MethodLength + def policy_config + # //secret/secret-name -> secret-name + target_links = template["targetLinks"]&.map do |target_link| + target_link.split("/").last + end + + # //group/viewers -> group/viewers + bindings = template["bindings"]&.map do |data| + principal_links = data.delete("principalLinks")&.map { |link| link.delete_prefix("//") } + data.merge("principalLinks" => principal_links) + end + + TerraformConfig::Policy.new( + name: template["name"], + description: template["description"], + tags: template["tags"], + target: template["target"], + target_kind: template["targetKind"], + target_query: template["targetQuery"], + target_links: target_links, + gvc: gvc, + bindings: bindings + ) + end + # rubocop:enable Metrics/MethodLength + + # GVC name matches application name + def gvc + "cpln_gvc.#{config.app}.name" + end + def env template.dig("spec", "env").to_h { |env_var| [env_var["name"], env_var["value"]] } end diff --git a/lib/core/terraform_config/gvc.rb b/lib/core/terraform_config/gvc.rb index 3142562c..355d38a4 100644 --- a/lib/core/terraform_config/gvc.rb +++ b/lib/core/terraform_config/gvc.rb @@ -24,7 +24,7 @@ def initialize( @locations = locations @pull_secrets = pull_secrets @env = env - @load_balancer = load_balancer&.underscore_keys&.symbolize_keys + @load_balancer = load_balancer&.deep_underscore_keys&.deep_symbolize_keys end # rubocop:enable Metrics/ParameterLists diff --git a/lib/core/terraform_config/policy.rb b/lib/core/terraform_config/policy.rb new file mode 100644 index 00000000..c8156293 --- /dev/null +++ b/lib/core/terraform_config/policy.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module TerraformConfig + # rubocop:disable Metrics/ClassLength + class Policy < Base + TARGET_KINDS = %w[ + agent auditctx cloudaccount domain group gvc identity image ipset kubernetes location + org policy quota secret serviceaccount task user volumeset workload + ].freeze + + GVC_REQUIERD_TARGET_KINDS = %w[identity workload volumeset].freeze + + attr_reader :name, :description, :tags, :target_kind, :gvc, :target, :target_links, :target_query, :bindings + + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength + def initialize( + name:, + description: nil, + tags: nil, + target_kind: nil, + gvc: nil, + target: nil, + target_links: nil, + target_query: nil, + bindings: nil + ) + super() + + @name = name + @description = description + @tags = tags + + @target_kind = target_kind + validate_target_kind! + + @gvc = gvc + validate_gvc! + + @target = target + @target_links = target_links + + @target_query = target_query&.deep_underscore_keys&.deep_symbolize_keys + @bindings = bindings&.map { |data| data.deep_underscore_keys.deep_symbolize_keys } + end + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength + + def to_tf + block :resource, :cpln_policy, name do + argument :name, name + + %i[description tags target_kind gvc target target_links].each do |arg_name| + argument arg_name, send(arg_name), optional: true + end + + bindings_tf + target_query_tf + end + end + + private + + def validate_target_kind! + return if target_kind.nil? || TARGET_KINDS.include?(target_kind.to_s) + + raise ArgumentError, "Invalid target kind given - #{target_kind}" + end + + def validate_gvc! + return unless GVC_REQUIERD_TARGET_KINDS.include?(target_kind.to_s) && gvc.nil? + + raise ArgumentError, "`gvc` is required for `#{target_kind}` target kind" + end + + def bindings_tf + return if bindings.nil? + + bindings.each do |binding_data| + block :binding do + argument :permissions, binding_data.fetch(:permissions, nil), optional: true + argument :principal_links, binding_data.fetch(:principal_links, nil), optional: true + end + end + end + + def target_query_tf + return if target_query.nil? + + fetch_type = target_query.fetch(:fetch, nil) + validate_fetch_type!(fetch_type) if fetch_type + + block :target_query do + argument :fetch, fetch_type, optional: true + target_query_spec_tf + end + end + + def validate_fetch_type!(fetch_type) + return if %w[links items].include?(fetch_type.to_s) + + raise ArgumentError, "Invalid fetch type - #{fetch_type}. Should be either `links` or `items`" + end + + def target_query_spec_tf + spec = target_query.fetch(:spec, nil) + return if spec.nil? + + match_type = spec.fetch(:match, nil) + validate_match_type!(match_type) if match_type + + block :spec do + argument :match, match_type, optional: true + + target_query_spec_terms_tf(spec) + end + end + + def validate_match_type!(match_type) + return if %w[all any none].include?(match_type.to_s) + + raise ArgumentError, "Invalid match type - #{match_type}. Should be either `all`, `any` or `none`" + end + + def target_query_spec_terms_tf(spec) + terms = spec.fetch(:terms, nil) + return if terms.nil? + + terms.each do |term| + validate_term!(term) + + block :terms do + %i[op property rel tag value].each do |arg_name| + argument arg_name, term.fetch(arg_name, nil), optional: true + end + end + end + end + + def validate_term!(term) + return unless (%i[property rel tag] & term.keys).count > 1 + + raise ArgumentError, + "`target_query.spec.terms` can contain only one of the following attributes: `property`, `rel`, `tag`." + end + end + # rubocop:enable Metrics/ClassLength +end diff --git a/lib/core/terraform_config/secret.rb b/lib/core/terraform_config/secret.rb index cf203013..ff4c2259 100644 --- a/lib/core/terraform_config/secret.rb +++ b/lib/core/terraform_config/secret.rb @@ -42,7 +42,7 @@ def to_tf def prepare_data(type:, data:) return data unless data.is_a?(Hash) - data.underscore_keys.symbolize_keys.tap do |prepared_data| + data.deep_underscore_keys.deep_symbolize_keys.tap do |prepared_data| validate_required_data_keys!(type: type, data: prepared_data) end end diff --git a/lib/patches/hash.rb b/lib/patches/hash.rb index 80e2b0a4..5d6afea0 100644 --- a/lib/patches/hash.rb +++ b/lib/patches/hash.rb @@ -2,16 +2,37 @@ class Hash # Copied from Rails - def symbolize_keys - transform_keys { |key| key.to_sym rescue key } # rubocop:disable Style/RescueModifier + def deep_symbolize_keys + deep_transform_keys { |key| key.to_sym rescue key } # rubocop:disable Style/RescueModifier end - def underscore_keys - transform_keys do |key| + def deep_underscore_keys + deep_transform_keys do |key| underscored = key.to_s.underscore key.is_a?(Symbol) ? underscored.to_sym : underscored rescue StandardError key end end + + private + + # Copied from Rails + def deep_transform_keys(&block) + deep_transform_keys_in_object(self, &block) + end + + # Copied from Rails + def deep_transform_keys_in_object(object, &block) + case object + when Hash + object.each_with_object(self.class.new) do |(key, value), result| + result[yield(key)] = deep_transform_keys_in_object(value, &block) + end + when Array + object.map { |e| deep_transform_keys_in_object(e, &block) } + else + object + end + end end diff --git a/spec/command/terraform/generate_spec.rb b/spec/command/terraform/generate_spec.rb index 6d049129..917eab18 100644 --- a/spec/command/terraform/generate_spec.rb +++ b/spec/command/terraform/generate_spec.rb @@ -99,7 +99,7 @@ def common_config_files end def app_config_files - %w[gvc.tf identities.tf secrets.tf].map do |config_file_path| + %w[gvc.tf identities.tf secrets.tf policies.tf].map do |config_file_path| TERRAFORM_CONFIG_DIR_PATH.join(app, config_file_path) end end diff --git a/spec/core/terraform_config/generator_spec.rb b/spec/core/terraform_config/generator_spec.rb index 3d8ad8df..3f6cbdc1 100644 --- a/spec/core/terraform_config/generator_spec.rb +++ b/spec/core/terraform_config/generator_spec.rb @@ -123,4 +123,56 @@ expect(tf_filename).to eq("secrets.tf") end end + + context "when template's kind is policy" do + let(:template) do + { + "kind" => "policy", + "name" => "policy-name", + "description" => "policy description", + "tags" => { "tag1" => "tag1_value", "tag2" => "tag2_value" }, + "target" => "all", + "targetKind" => "secret", + "targetLinks" => [ + "//secret/postgres-poc-credentials", + "//secret/postgres-poc-entrypoint-script" + ], + "bindings" => [ + { + "permissions" => %w[reveal view use], + "principalLinks" => %W[//gvc/#{config.app}/identity/postgres-poc-identity] + }, + { + "permissions" => %w[view], + "principalLinks" => %w[user/fake-user@fake-email.com] + } + ] + } + end + + it "generates correct terraform config and filename for it", :aggregate_failures do + tf_config = generator.tf_config + expect(tf_config).to be_an_instance_of(TerraformConfig::Policy) + + expect(tf_config.name).to eq("policy-name") + expect(tf_config.description).to eq("policy description") + expect(tf_config.tags).to eq("tag1" => "tag1_value", "tag2" => "tag2_value") + expect(tf_config.target).to eq("all") + expect(tf_config.target_kind).to eq("secret") + expect(tf_config.target_links).to eq(%w[postgres-poc-credentials postgres-poc-entrypoint-script]) + expect(tf_config.bindings).to contain_exactly( + { + permissions: %w[reveal view use], + principal_links: %W[gvc/#{config.app}/identity/postgres-poc-identity] + }, + { + permissions: %w[view], + principal_links: %w[user/fake-user@fake-email.com] + } + ) + + tf_filename = generator.filename + expect(tf_filename).to eq("policies.tf") + end + end end diff --git a/spec/core/terraform_config/policy_spec.rb b/spec/core/terraform_config/policy_spec.rb new file mode 100644 index 00000000..cac57eed --- /dev/null +++ b/spec/core/terraform_config/policy_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe TerraformConfig::Policy do + let(:config) { described_class.new(**base_options.merge(extra_options)) } + + describe "#to_tf" do + subject(:generated) { config.to_tf } + + let(:base_options) do + { + name: "policy-name", + description: "policy description", + tags: { "tag1" => "true", "tag2" => "false" }, + target_links: ["secret/postgres-poc-credentials", "secret/postgres-poc-entrypoint-script"], + bindings: [ + { + "permissions" => %w[view], + "principalLinks" => [ + "user/fake-user@fake-email.com", + "serviceaccount/FAKE_SERVICE_ACCOUNT_NAME", + "group/FAKE-GROUP" + ] + }, + { + "permissions" => %w[view edit], + "principalLinks" => ["user/fake-admin-user@fake-email.com"] + } + ] + } + end + + let(:extra_options) { {} } + + context "with target query" do + let(:extra_options) do + { + target_kind: "agent", + target_query: { + "kind" => "agent", + "fetch" => fetch_type, + "spec" => { + "match" => match_type, + "terms" => [ + { + "op" => "=", + "tag" => "tag_name", + "value" => "some_tag" + } + ] + } + } + } + end + + let(:fetch_type) { "items" } + let(:match_type) { "all" } + + it "generates correct config" do + expect(generated).to eq( + <<~EXPECTED + resource "cpln_policy" "policy-name" { + name = "policy-name" + description = "policy description" + tags = { + tag1 = "true" + tag2 = "false" + } + target_kind = "agent" + target_links = ["secret/postgres-poc-credentials", "secret/postgres-poc-entrypoint-script"] + binding { + permissions = ["view"] + principal_links = ["user/fake-user@fake-email.com", "serviceaccount/FAKE_SERVICE_ACCOUNT_NAME", "group/FAKE-GROUP"] + } + binding { + permissions = ["view", "edit"] + principal_links = ["user/fake-admin-user@fake-email.com"] + } + target_query { + fetch = "items" + spec { + match = "all" + terms { + op = "=" + tag = "tag_name" + value = "some_tag" + } + } + } + } + EXPECTED + ) + end + + context "when fetch type is invalid" do + let(:fetch_type) { "invalid" } + + it "raises an argument error" do + expect { generated }.to raise_error( + ArgumentError, + "Invalid fetch type - #{fetch_type}. Should be either `links` or `items`" + ) + end + end + + context "when match type is invalid" do + let(:match_type) { "invalid" } + + it "raises an argument error" do + expect { generated }.to raise_error( + ArgumentError, + "Invalid match type - #{match_type}. Should be either `all`, `any` or `none`" + ) + end + end + + context "when term is invalid" do + let(:extra_options) do + { + target_query: { + "spec" => { + "terms" => [ + { + "property" => "id", # extra attribute + "tag" => "tag_name" + } + ] + } + } + } + end + + it "raises an argument error" do + expect { generated }.to raise_error( + ArgumentError, + "`target_query.spec.terms` can contain only one of the following attributes: `property`, `rel`, `tag`." + ) + end + end + end + + context "without target query" do + let(:extra_options) { {} } + + it "generates correct config" do + expect(generated).to eq( + <<~EXPECTED + resource "cpln_policy" "policy-name" { + name = "policy-name" + description = "policy description" + tags = { + tag1 = "true" + tag2 = "false" + } + target_links = ["secret/postgres-poc-credentials", "secret/postgres-poc-entrypoint-script"] + binding { + permissions = ["view"] + principal_links = ["user/fake-user@fake-email.com", "serviceaccount/FAKE_SERVICE_ACCOUNT_NAME", "group/FAKE-GROUP"] + } + binding { + permissions = ["view", "edit"] + principal_links = ["user/fake-admin-user@fake-email.com"] + } + } + EXPECTED + ) + end + end + + context "when gvc is required" do + let(:extra_options) { { target_kind: "identity", gvc: nil } } + + it "raises error if gvc is missing" do + expect { generated }.to raise_error(ArgumentError, "`gvc` is required for `identity` target kind") + end + end + end +end diff --git a/spec/dummy/.controlplane/templates/policy.yml b/spec/dummy/.controlplane/templates/policy.yml new file mode 100644 index 00000000..000b108f --- /dev/null +++ b/spec/dummy/.controlplane/templates/policy.yml @@ -0,0 +1,16 @@ +kind: identity +name: postgres-poc-identity +description: postgres-poc-identity +--- +kind: policy +name: postgres-poc-access +description: postgres-poc-access +bindings: + - permissions: + - view + principalLinks: + - //gvc/{{APP_NAME}}/identity/postgres-poc-identity +targetKind: secret +targetLinks: + - //secret/postgres-poc-credentials + - //secret/postgres-poc-entrypoint-script diff --git a/spec/pathces/hash_spec.rb b/spec/pathces/hash_spec.rb index 6c1216fb..afe7dd07 100644 --- a/spec/pathces/hash_spec.rb +++ b/spec/pathces/hash_spec.rb @@ -3,22 +3,22 @@ require "spec_helper" describe Hash do - describe "#underscore_keys" do - subject(:underscored_keys_hash) { hash.underscore_keys } + describe "#deep_underscore_keys" do + subject(:deep_underscored_keys_hash) { hash.deep_underscore_keys } context "with an empty hash" do let(:hash) { {} } it "returns an empty hash" do - expect(underscored_keys_hash).to eq({}) + expect(deep_underscored_keys_hash).to eq({}) end end context "with a nested hash" do let(:hash) { { "outerCamelCase" => { innerCamelCase: "value" } } } - it "transforms keys only at top level" do - expect(underscored_keys_hash).to eq("outer_camel_case" => { innerCamelCase: "value" }) + it "transforms keys at all levels" do + expect(deep_underscored_keys_hash).to eq("outer_camel_case" => { inner_camel_case: "value" }) end end @@ -26,7 +26,7 @@ let(:hash) { { "already_underscored" => "value" } } it "leaves underscored keys unchanged" do - expect(underscored_keys_hash).to eq("already_underscored" => "value") + expect(deep_underscored_keys_hash).to eq("already_underscored" => "value") end end @@ -34,7 +34,7 @@ let(:hash) { { "camelCase123" => "value1", "special@CaseKey" => "value2" } } it "correctly transforms keys with numbers or special characters" do - expect(underscored_keys_hash).to eq("camel_case123" => "value1", "special@case_key" => "value2") + expect(deep_underscored_keys_hash).to eq("camel_case123" => "value1", "special@case_key" => "value2") end end @@ -42,15 +42,15 @@ let(:hash) { { "camelCaseKey" => "value1", "snake_case_key" => "value2", "XMLHttpRequest" => "value3" } } it "transforms camelCase keys to snake_case" do - expect(underscored_keys_hash["camel_case_key"]).to eq("value1") + expect(deep_underscored_keys_hash["camel_case_key"]).to eq("value1") end it "leaves snake_case keys unchanged" do - expect(underscored_keys_hash["snake_case_key"]).to eq("value2") + expect(deep_underscored_keys_hash["snake_case_key"]).to eq("value2") end it "correctly handles keys with multiple uppercase letters" do - expect(underscored_keys_hash["xml_http_request"]).to eq("value3") + expect(deep_underscored_keys_hash["xml_http_request"]).to eq("value3") end end @@ -58,15 +58,15 @@ let(:hash) { { camelCaseKey: "value1", snake_case_key: "value2", XMLHttpRequest: "value3" } } it "transforms camelCase symbol keys to snake_case" do - expect(underscored_keys_hash[:camel_case_key]).to eq("value1") + expect(deep_underscored_keys_hash[:camel_case_key]).to eq("value1") end it "leaves snake_case symbol keys unchanged" do - expect(underscored_keys_hash[:snake_case_key]).to eq("value2") + expect(deep_underscored_keys_hash[:snake_case_key]).to eq("value2") end it "correctly handles symbol keys with multiple uppercase letters" do - expect(underscored_keys_hash[:xml_http_request]).to eq("value3") + expect(deep_underscored_keys_hash[:xml_http_request]).to eq("value3") end end end