diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index edddc2d..32d22d1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,7 @@ "forwardPorts": [3000, 5432, 8200], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "bundle install && rake vault:setup && rake db:setup", + "postCreateCommand": "bundle install && rake db:setup", // Configure tool-specific properties. // "customizations": {}, diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 86eb01a..dcafb8a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -14,6 +14,7 @@ services: environment: VAULT_ADDR: http://vault:8200 VAULT_TOKEN: root_token + VAULT_CREATE_ROOT: true VAULT_ROOT_CA_MOUNT: pki VAULT_ROOT_CA_REF: root-ca JWT_SIGNING_KEY: jwt_secret diff --git a/Gemfile b/Gemfile index abcd601..126add0 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,7 @@ gem "bootsnap", require: false # High-level app logic gem "interactor", "~> 3.0" +gem "ostruct" # Use the vault-ruby gem to interact with HashiCorp Vault gem "vault" diff --git a/Gemfile.lock b/Gemfile.lock index f416225..2f10853 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,6 +152,7 @@ GEM racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) + ostruct (0.6.0) parallel (1.26.2) parser (3.3.4.2) ast (~> 2.4.1) @@ -290,6 +291,7 @@ DEPENDENCIES interactor (~> 3.0) jbuilder jwt + ostruct puma (>= 5.0) rails (~> 7.2.1) rubocop-rails-omakase diff --git a/app/lib/clients/vault/certificate.rb b/app/lib/clients/vault/certificate.rb index 1aa9363..bd83cd6 100644 --- a/app/lib/clients/vault/certificate.rb +++ b/app/lib/clients/vault/certificate.rb @@ -9,10 +9,12 @@ def issue_cert(cert_issue_request) end def configure_pki - if enable_ca - sign_cert - configure_ca - end + # if intermediate mount exists, assume configuration is done + return if client.sys.mounts.key?(intermediate_ca_mount.to_sym) + configure_root_ca if create_root? + enable_ca + sign_cert + configure_ca end private @@ -25,6 +27,11 @@ def cert_path "#{intermediate_ca_mount}/issue/astral" end + def create_root? + create_root_config = Rails.configuration.astral[:vault_create_root] + !!ActiveModel::Type::Boolean.new.cast(create_root_config) + end + def root_ca_ref Rails.configuration.astral[:vault_root_ca_ref] end @@ -38,36 +45,55 @@ def cert_engine_type end def enable_ca - # if mount exists, assume configuration is done - if client.sys.mounts.key?(intermediate_ca_mount.to_sym) - return false - end - - # create the mount + # create the intermediate mount enable_engine(intermediate_ca_mount, cert_engine_type) - true + end + + def configure_root_ca + return if client.sys.mounts.key?(root_ca_mount.to_sym) + + # enable engine + enable_engine(root_ca_mount, cert_engine_type) + + # generate root certificate + root_cert = client.logical.write("#{root_ca_mount}/root/generate/internal", + common_name: "astral.internal", + issuer_name: root_ca_ref, + ttl: "87600h").data[:certificate] + # save the root certificate + File.write("tmp/#{root_ca_mount}.crt", root_cert) + + client.logical.write("#{root_ca_mount}/config/cluster", + path: "#{vault_address}/v1/#{root_ca_mount}", + aia_path: "#{vault_address}/v1/#{root_ca_mount}") + + client.logical.write("#{root_ca_mount}/config/urls", + issuing_certificates: "{{cluster_aia_path}}/issuer/{{issuer_id}}/der", + crl_distribution_points: "{{cluster_aia_path}}/issuer/{{issuer_id}}/crl/der", + ocsp_servers: "{{cluster_path}}/ocsp", + enable_templating: true) end def sign_cert - # Generate intermediate CSR + # generate intermediate CSR intermediate_csr = client.logical.write("#{intermediate_ca_mount}/intermediate/generate/internal", common_name: "astral.internal Intermediate Authority", issuer_name: "astral-intermediate").data[:csr] - # Save the intermediate CSR - File.write("tmp/pki_intermediate.csr", intermediate_csr) + # save the intermediate CSR + File.write("tmp/#{intermediate_ca_mount}.csr", intermediate_csr) - # Sign the intermediate certificate with the root CA + # sign the intermediate certificate with the root CA intermediate_cert = client.logical.write("#{root_ca_mount}/root/sign-intermediate", issuer_ref: root_ca_ref, csr: intermediate_csr, format: "pem_bundle", ttl: "43800h").data[:certificate] - # Save the signed intermediate certificate - File.write("tmp/intermediate.cert.pem", intermediate_cert) + # save the signed intermediate certificate + File.write("tmp/#{intermediate_ca_mount}.crt", intermediate_cert) - # Set the signed intermediate certificate + # set the signed intermediate certificate client.logical.write("#{intermediate_ca_mount}/intermediate/set-signed", certificate: intermediate_cert) end diff --git a/config/astral.yml b/config/astral.yml index 3093e7b..f8acdeb 100644 --- a/config/astral.yml +++ b/config/astral.yml @@ -1,9 +1,11 @@ shared: vault_addr: <%= ENV["VAULT_ADDR"] %> vault_token: <%= ENV["VAULT_TOKEN"] %> - # Pre-existing root CA in Vault for signing intermediate created by astral + + # Pre-existing root CA, or create new if requested + vault_create_root: <%= ENV["VAULT_CREATE_ROOT"] || "true" %> vault_root_ca_ref: <%= ENV["VAULT_ROOT_CA_REF"] || "root-ca" %> - vault_root_ca_mount: <%= ENV["VAULT_ROOT_CA_MOUNT"] || "pki" %> + vault_root_ca_mount: <%= ENV["VAULT_ROOT_CA_MOUNT"] || "pki_root" %> jwt_signing_key: <%= ENV["JWT_SIGNING_KEY"] %> cert_ttl: <%= ENV["CERT_TTL"] %> diff --git a/lib/tasks/vault.rake b/lib/tasks/vault.rake deleted file mode 100644 index ca06432..0000000 --- a/lib/tasks/vault.rake +++ /dev/null @@ -1,69 +0,0 @@ -require "rake" -require "vault" -require "json" - -# Define Rake tasks -namespace :vault do - desc "Setup PKI root certificate authority" - task :setup do - unless Rails.env.development? - raise "This task should only be used in development" - end - Vault.address = ENV["VAULT_ADDR"] - Vault.token = ENV["VAULT_TOKEN"] - ensure_root_cert - configure_root_cert - end -end - -# Helper methods -def enable_pki(path, max_ttl) - unless Vault.sys.mounts.key?(path + "/") - Vault.sys.mount(path, "pki", "PKI Secrets Engine") - else - puts "#{path} already enabled." - end -rescue Vault::HTTPError => e - puts "Error enabling pki, already enabled?: #{e}" -end - -def ensure_root_cert - enable_pki(root_mount, "87600h") - - # Generate root certificate - root_cert = Vault.logical.write("pki/root/generate/internal", - common_name: "astral.internal", - issuer_name: root_issuer_name, - ttl: "87600h").data[:certificate] - - # Save the root certificate - File.write("tmp/#{root_issuer_name}.crt", root_cert) -rescue Vault::HTTPError => e - puts "Error enabling root pki, already enabled?: #{e}" -end - -def configure_root_cert - Vault.logical.write("#{root_mount}/config/cluster", - path: "#{ENV["VAULT_ADDR"]}/v1/#{root_mount}", - aia_path: "#{ENV["VAULT_ADDR"]}/v1/#{root_mount}") - - Vault.logical.write("#{root_mount}/roles/2024-servers", - allow_any_name: true, - no_store: false) - - Vault.logical.write("#{root_mount}/config/urls", - issuing_certificates: "{{cluster_aia_path}}/issuer/{{issuer_id}}/der", - crl_distribution_points: "{{cluster_aia_path}}/issuer/{{issuer_id}}/crl/der", - ocsp_servers: "{{cluster_path}}/ocsp", - enable_templating: true) -rescue Vault::HTTPError => e - puts "Error configuring root pki: #{e}" -end - -def root_issuer_name - ENV["VAULT_ROOT_CA_REF"] -end - -def root_mount - ENV["VAULT_ROOT_CA_MOUNT"] -end diff --git a/test/integration/secrets_test.rb b/test/integration/secrets_test.rb index 54f56b0..bdb1ac8 100644 --- a/test/integration/secrets_test.rb +++ b/test/integration/secrets_test.rb @@ -20,9 +20,9 @@ class SecretsTest < ActionDispatch::IntegrationTest end test "#show" do - create_secret + path = create_secret # view the secret - get secret_path("top/secret/key"), headers: { "Authorization" => "Bearer #{jwt_authorized}" } + get secret_path(path), headers: { "Authorization" => "Bearer #{jwt_authorized}" } assert_response :success %w[ data metadata lease_id ].each do |key| assert_includes response.parsed_body["secret"].keys, key @@ -30,18 +30,21 @@ class SecretsTest < ActionDispatch::IntegrationTest end test "#delete" do - create_secret + path = create_secret # delete the secret - delete destroy_secret_path("top/secret/key"), headers: { "Authorization" => "Bearer #{jwt_authorized}" } + delete destroy_secret_path(path), headers: { "Authorization" => "Bearer #{jwt_authorized}" } assert_response :success end private def create_secret + # make a path + path = "top/secret/#{SecureRandom.hex}" # create the secret post secrets_path, headers: { "Authorization" => "Bearer #{jwt_authorized}" }, - params: { secret: { path: "top/secret/key", data: { password: "sicr3t" } } } + params: { secret: { path: path, data: { password: "sicr3t" } } } + path end def remove_pki_engine diff --git a/test/lib/clients/vault_test.rb b/test/lib/clients/vault_test.rb index 27fc48a..ce59860 100644 --- a/test/lib/clients/vault_test.rb +++ b/test/lib/clients/vault_test.rb @@ -1,48 +1,56 @@ require "test_helper" class VaultTest < ActiveSupport::TestCase - attr_reader :random_mount + attr_reader :root_ca_mount + attr_reader :intermediate_ca_mount attr_reader :policies attr_reader :entity_name attr_reader :alias_name setup do @client = Clients::Vault - @random_mount = SecureRandom.hex(4) + @root_ca_mount = SecureRandom.hex(4) + @intermediate_ca_mount = SecureRandom.hex(4) @policies = SecureRandom.hex(4) @entity_name = SecureRandom.hex(4) @alias_name = SecureRandom.hex(4) - end + end teardown do - vault_client.sys.unmount(random_mount) + vault_client.sys.unmount(root_ca_mount) + vault_client.sys.unmount(intermediate_ca_mount) end test "#configure_kv" do - @client.stub :kv_mount, random_mount do + @client.stub :kv_mount, intermediate_ca_mount do assert @client.configure_kv engines = vault_client.sys.mounts - assert_equal "kv", engines[random_mount.to_sym].type + assert_equal "kv", engines[intermediate_ca_mount.to_sym].type end end test "#configure_pki" do - @client.stub :intermediate_ca_mount, random_mount do - assert @client.configure_pki - engines = vault_client.sys.mounts - assert_equal "pki", engines[random_mount.to_sym].type - - read_cert = vault_client.logical.read("#{random_mount}/cert/ca").data[:certificate] - assert_match "BEGIN CERTIFICATE", read_cert - - cluster_config = vault_client.logical.read("#{random_mount}/config/cluster").data - assert_equal "#{vault_addr}/v1/#{random_mount}", cluster_config[:path] - assert_equal "#{vault_addr}/v1/#{random_mount}", cluster_config[:aia_path] - - role_config = vault_client.logical.read("#{random_mount}/roles/astral").data - assert_not_nil role_config[:issuer_ref] - assert_equal 720.hours, role_config[:max_ttl] - assert_equal true, role_config[:allow_any_name] - end + @client.stub :root_ca_mount, root_ca_mount do + @client.stub :intermediate_ca_mount, intermediate_ca_mount do + assert @client.configure_pki + + [ root_ca_mount, intermediate_ca_mount ].each do |mount| + engines = vault_client.sys.mounts + assert_equal "pki", engines[mount.to_sym].type + + read_cert = vault_client.logical.read("#{mount}/cert/ca").data[:certificate] + assert_match "BEGIN CERTIFICATE", read_cert + + cluster_config = vault_client.logical.read("#{mount}/config/cluster").data + assert_equal "#{vault_addr}/v1/#{mount}", cluster_config[:path] + assert_equal "#{vault_addr}/v1/#{mount}", cluster_config[:aia_path] + end + + role_config = vault_client.logical.read("#{intermediate_ca_mount}/roles/astral").data + assert_not_nil role_config[:issuer_ref] + assert_equal 720.hours, role_config[:max_ttl] + assert_equal true, role_config[:allow_any_name] + end + end end test "#entity" do