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 43e6f1a1..901e0d81 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -46,7 +46,7 @@ def generate_app_config 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 b63b627c..5636a5cb 100644 --- a/lib/core/terraform_config/generator.rb +++ b/lib/core/terraform_config/generator.rb @@ -6,84 +6,102 @@ class Generator def initialize(config:, template:) @config = config - @template = template + @template = template.deep_underscore_keys.deep_symbolize_keys end - def filename - case template["kind"] + def filename # rubocop:disable Metrics/MethodLength + case kind when "gvc" "gvc.tf" - when "identity" - "identities.tf" when "secret" "secrets.tf" + when "identity" + "identities.tf" + when "policy" + "policies.tf" else - raise "Unsupported template kind - #{template['kind']}" + raise "Unsupported template kind - #{kind}" end end 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 = :"#{kind}_config" + raise "Unsupported template kind - #{kind}" unless self.class.private_method_defined?(method_name) + + send(method_name) end private - def gvc_config # rubocop:disable Metrics/MethodLength - pull_secrets = template.dig("spec", "pullSecretLinks")&.map do |secret_link| - secret_name = secret_link.split("/").last - "cpln_secret.#{secret_name}.name" - end - - load_balancer = template.dig("spec", "loadBalancer") + def kind + @kind ||= template[:kind] + end + def gvc_config # rubocop:disable Metrics/MethodLength TerraformConfig::Gvc.new( - name: template["name"], - description: template["description"], - tags: template["tags"], - domain: template.dig("spec", "domain"), - env: env, - pull_secrets: pull_secrets, - locations: locations, - load_balancer: load_balancer + **template + .slice(:name, :description, :tags) + .merge( + env: gvc_env, + pull_secrets: gvc_pull_secrets, + locations: gvc_locations, + domain: template.dig(:spec, :domain), + load_balancer: template.dig(:spec, :load_balancer) + ) ) end def identity_config - TerraformConfig::Identity.new( - gvc: "cpln_gvc.#{config.app}.name", # GVC name matches application name - name: template["name"], - description: template["description"], - tags: template["tags"] - ) + TerraformConfig::Identity.new(**template.slice(:name, :description, :tags).merge(gvc: gvc)) end def secret_config - TerraformConfig::Secret.new( - name: template["name"], - description: template["description"], - type: template["type"], - data: template["data"], - tags: template["tags"] + TerraformConfig::Secret.new(**template.slice(:name, :description, :type, :data, :tags)) + end + + def policy_config + TerraformConfig::Policy.new( + **template + .slice(:name, :description, :tags, :target, :target_kind, :target_query) + .merge(gvc: gvc, target_links: policy_target_links, bindings: policy_bindings) ) end - def env - template.dig("spec", "env").to_h { |env_var| [env_var["name"], env_var["value"]] } + # GVC name matches application name + def gvc + "cpln_gvc.#{config.app}.name" + end + + def gvc_pull_secrets + template.dig(:spec, :pull_secret_links)&.map do |secret_link| + secret_name = secret_link.split("/").last + "cpln_secret.#{secret_name}.name" + end + end + + def gvc_env + template.dig(:spec, :env).to_h { |env_var| [env_var[:name], env_var[:value]] } end - def locations - template.dig("spec", "staticPlacement", "locationLinks")&.map do |location_link| + def gvc_locations + template.dig(:spec, :static_placement, :location_links)&.map do |location_link| location_link.split("/").last end end + + # //secret/secret-name -> secret-name + def policy_target_links + template[:target_links]&.map do |target_link| + target_link.split("/").last + end + end + + # //group/viewers -> group/viewers + def policy_bindings + template[:bindings]&.map do |data| + principal_links = data.delete(:principal_links)&.map { |link| link.delete_prefix("//") } + data.merge(principal_links: principal_links) + end + end end end diff --git a/lib/core/terraform_config/gvc.rb b/lib/core/terraform_config/gvc.rb index 310d2e71..606ee23d 100644 --- a/lib/core/terraform_config/gvc.rb +++ b/lib/core/terraform_config/gvc.rb @@ -23,7 +23,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists @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 def to_tf diff --git a/lib/core/terraform_config/policy.rb b/lib/core/terraform_config/policy.rb new file mode 100644 index 00000000..65d0692a --- /dev/null +++ b/lib/core/terraform_config/policy.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module TerraformConfig + class Policy < Base # rubocop:disable Metrics/ClassLength + 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_REQUIRED_TARGET_KINDS = %w[identity workload volumeset].freeze + + attr_reader :name, :description, :tags, :target_kind, :gvc, :target, :target_links, :target_query, :bindings + + def initialize( # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength + 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 + + 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_REQUIRED_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, + "Each term in `target_query.spec.terms` must contain exactly one of the following attributes: " \ + "`property`, `rel`, or `tag`." + end + end +end diff --git a/lib/core/terraform_config/secret.rb b/lib/core/terraform_config/secret.rb index 4657fcbf..efbb7515 100644 --- a/lib/core/terraform_config/secret.rb +++ b/lib/core/terraform_config/secret.rb @@ -41,7 +41,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 21fa52be..d4fa9d06 100644 --- a/spec/command/terraform/generate_spec.rb +++ b/spec/command/terraform/generate_spec.rb @@ -95,7 +95,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..8034da97 100644 --- a/spec/core/terraform_config/generator_spec.rb +++ b/spec/core/terraform_config/generator_spec.rb @@ -57,7 +57,7 @@ expect(tf_config.name).to eq(config.app) expect(tf_config.description).to eq("description") - expect(tf_config.tags).to eq("tag1" => "tag1_value", "tag2" => "tag2_value") + expect(tf_config.tags).to eq(tag1: "tag1_value", tag2: "tag2_value") expect(tf_config.domain).to eq("app.example.com") expect(tf_config.locations).to eq(["aws-us-east-2"]) @@ -92,7 +92,7 @@ expect(tf_config.name).to eq("identity-name") expect(tf_config.description).to eq("description") - expect(tf_config.tags).to eq("tag1" => "tag1_value", "tag2" => "tag2_value") + expect(tf_config.tags).to eq(tag1: "tag1_value", tag2: "tag2_value") tf_filename = generator.filename expect(tf_filename).to eq("identities.tf") @@ -117,10 +117,62 @@ expect(tf_config.name).to eq("secret-name") expect(tf_config.description).to eq("description") - expect(tf_config.tags).to eq("tag1" => "tag1_value", "tag2" => "tag2_value") + expect(tf_config.tags).to eq(tag1: "tag1_value", tag2: "tag2_value") tf_filename = generator.filename 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..f0b742a1 --- /dev/null +++ b/spec/core/terraform_config/policy_spec.rb @@ -0,0 +1,180 @@ +# 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, + "Each term in `target_query.spec.terms` must contain exactly one of the following attributes: " \ + "`property`, `rel`, or `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