diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index e73e950..b8f3f66 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -31,6 +31,7 @@ services: volumes: - ../cert:/vault/cert environment: + VAULT_LOG_LEVEL: debug VAULT_DEV_ROOT_TOKEN_ID: root_token VAULT_LOCAL_CONFIG: > { diff --git a/Gemfile b/Gemfile index 6899b36..018d82e 100644 --- a/Gemfile +++ b/Gemfile @@ -60,4 +60,7 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false + + # Mocking for tests + gem "mocha" end diff --git a/Gemfile.lock b/Gemfile.lock index 6f4557d..2ee8b55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -132,6 +132,8 @@ GEM marcel (1.0.4) mini_mime (1.1.5) minitest (5.25.1) + mocha (2.5.0) + ruby2_keywords (>= 0.0.5) msgpack (1.7.2) net-http (0.4.1) uri @@ -243,6 +245,7 @@ GEM rubocop-performance rubocop-rails ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) securerandom (0.3.1) sqlite3 (2.2.0-aarch64-linux-gnu) sqlite3 (2.2.0-aarch64-linux-musl) @@ -300,6 +303,7 @@ DEPENDENCIES jbuilder json_tagged_logger jwt + mocha ostruct puma (>= 5.0) rails (~> 7.2.2) diff --git a/README.md b/README.md index 0caa8fa..c7699fe 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Some features of Astral: 0) Configure Astral-specific Certificate Authority and Key-Value stores in Vault 1) Authenticate requests for cerficates or secrets using a third party trusted source (JWT with signing key, eg) -2) For certiciates: +2) For certificates: a) Authorize the request using a Domain Ownership registry, where domain owner or authorized groups must match the identity of the requesting client b) When authorized, obtain a certificate for the common name @@ -162,9 +162,6 @@ config/astral.yml). The rails test's configure the OIDC initial user, so if the tests pass, you can invoke the oidc login as follows: - -To use SSL in production, provide the necessary environment (SSL_CERT, SSL_KEY) to -the container environment, and use the `bin/ssl.sh` startup command. Eg: ``` export VAULT_ADDR=http://127.0.0.1:8200; vault login -method=oidc ``` diff --git a/app/controllers/secrets_controller.rb b/app/controllers/secrets_controller.rb index 331ec24..1f5b5d2 100644 --- a/app/controllers/secrets_controller.rb +++ b/app/controllers/secrets_controller.rb @@ -39,6 +39,6 @@ def destroy private def params_permitted - params.require(:secret).permit(:path, data: {}) + params.require(:secret).permit(:path, :groups, data: {}) end end diff --git a/app/interactors/authorize_cert_request.rb b/app/interactors/authorize_cert_request.rb index 15183b1..ab68bd5 100644 --- a/app/interactors/authorize_cert_request.rb +++ b/app/interactors/authorize_cert_request.rb @@ -4,8 +4,8 @@ def call domain = Domain.where(fqdn: fqdn).first context.fail!(error: AuthError.new("Common or alt name not recognized")) unless domain context.fail!(error: AuthError.new("No subject or group authorization")) unless - domain.users_array.include?(context.identity.subject) || - (domain.group_delegation? && (domain.groups_array & context.identity.groups).any?) + domain.users.include?(context.identity.subject) || + (domain.group_delegation? && (domain.groups & context.identity.groups).any?) end nil ensure diff --git a/app/interactors/write_secret.rb b/app/interactors/write_secret.rb index ec4ea4c..6460a49 100644 --- a/app/interactors/write_secret.rb +++ b/app/interactors/write_secret.rb @@ -1,6 +1,6 @@ class WriteSecret < ApplicationInteractor def call - if secret = Services::KeyValue.write(context.identity, context.request.path, context.request.data) + if secret = Services::KeyValue.write(context.identity, context.request.groups_array, context.request.path, context.request.data) context.secret = secret else context.fail!(message: "Failed to store secret") diff --git a/app/lib/clients/app_registry.rb b/app/lib/clients/app_registry.rb index 23e6b3c..2083867 100644 --- a/app/lib/clients/app_registry.rb +++ b/app/lib/clients/app_registry.rb @@ -27,8 +27,8 @@ def convert(domain_info) OpenStruct.new( fqdn: domain_info["fullyQualifiedDomainName"], group_delegation: domain_info["ownerDelegatedRequestsToTeam"], - groups: domain_info["autoApprovedGroups"], - users: domain_info["autoApprovedServiceAccounts"] + groups: domain_info["autoApprovedGroups"]&.split(","), + users: domain_info["autoApprovedServiceAccounts"]&.split(",") ) end diff --git a/app/lib/clients/vault.rb b/app/lib/clients/vault.rb index 5a68e85..690363e 100644 --- a/app/lib/clients/vault.rb +++ b/app/lib/clients/vault.rb @@ -3,8 +3,8 @@ class Vault extend Clients::Vault::Certificate extend Clients::Vault::KeyValue extend Clients::Vault::Policy - extend Clients::Vault::Entity - extend Clients::Vault::EntityAlias + extend Clients::Vault::Identity + extend Clients::Vault::IdentityAlias extend Clients::Vault::Oidc class_attribute :token diff --git a/app/lib/clients/vault/certificate.rb b/app/lib/clients/vault/certificate.rb index 5f82ac5..9436d73 100644 --- a/app/lib/clients/vault/certificate.rb +++ b/app/lib/clients/vault/certificate.rb @@ -9,7 +9,7 @@ def issue_cert(identity, cert_issue_request) opts = cert_issue_request.attributes # Generate the TLS certificate using the intermediate CA tls_cert = client.logical.write(cert_path, opts) - assign_policy(identity, GENERIC_CERT_POLICY_NAME) + assign_entity_policy(identity, GENERIC_CERT_POLICY_NAME) OpenStruct.new tls_cert.data end diff --git a/app/lib/clients/vault/entity.rb b/app/lib/clients/vault/entity.rb deleted file mode 100644 index 6216d31..0000000 --- a/app/lib/clients/vault/entity.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Clients - class Vault - module Entity - def put_entity(name, policies, metadata = {}) - client.logical.write("identity/entity", - name: name, - policies: policies, - metadata: metadata) - end - - def read_entity(name) - client.logical.read("identity/entity/name/#{name}") - end - - def delete_entity(name) - client.logical.delete("identity/entity/name/#{name}") - end - - def get_entity_data(sub) - entity = read_entity(sub) - if entity.nil? - [ [], nil ] - else - [ entity.data[:policies], entity.data[:metadata] ] - end - end - end - end -end diff --git a/app/lib/clients/vault/entity_alias.rb b/app/lib/clients/vault/entity_alias.rb deleted file mode 100644 index 88e42cc..0000000 --- a/app/lib/clients/vault/entity_alias.rb +++ /dev/null @@ -1,47 +0,0 @@ -module Clients - class Vault - module EntityAlias - def put_entity_alias(entity_name, alias_name, auth_path) - e = read_entity(entity_name) - if e.nil? - raise "no such entity #{entity_name}" - end - canonical_id = e.data[:id] - auth_sym = "#{auth_path}/".to_sym - accessor = client.logical.read("/sys/auth").data[auth_sym][:accessor] - client.logical.write("identity/entity-alias", - name: alias_name, - canonical_id: canonical_id, - mount_accessor: accessor) - end - - def read_entity_alias_id(entity_name, alias_name, auth_path) - e = read_entity(entity_name) - if e.nil? - raise "no such entity #{entity_name}" - end - aliases = e.data[:aliases] - a = find_alias(aliases, alias_name, auth_path) - if a.nil? - raise "no such alias #{alias_name}" - end - a[:id] - end - - def read_entity_alias(entity_name, alias_name, auth_path) - id = read_entity_alias_id(entity_name, alias_name, auth_path) - client.logical.read("identity/entity-alias/id/#{id}") - end - - def delete_entity_alias(entity_name, alias_name, auth_path) - id = read_entity_alias_id(entity_name, alias_name, auth_path) - client.logical.delete("identity/entity-alias/id/#{id}") - end - - private - def find_alias(aliases, name, auth_path) - aliases.find { |a| a[:name] == name && a[:mount_path] == "auth/#{auth_path}/" } - end - end - end -end diff --git a/app/lib/clients/vault/identity.rb b/app/lib/clients/vault/identity.rb new file mode 100644 index 0000000..aa42434 --- /dev/null +++ b/app/lib/clients/vault/identity.rb @@ -0,0 +1,72 @@ +module Clients + class Vault + module Identity + def put_entity(name, policies) + write_identity(path: "identity/entity", + name: name, + policies: policies, + extra_params: [ :metadata, :disabled ]) + end + + def put_group(name, policies) + write_identity(path: "identity/group", + name: name, + policies: policies, + extra_params: [ :metadata, :type, :member_group_ids, :member_entity_ids ], + defaults: { type: "external" }) + end + + def read_entity(name) + client.logical.read("identity/entity/name/#{name}") + end + + def delete_entity(name) + client.logical.delete("identity/entity/name/#{name}") + end + + def get_entity_data(name) + get_identity_data("identity/entity/name/#{name}") + end + + def read_group(name) + client.logical.read("identity/group/name/#{name}") + end + + def get_group_data(name) + get_identity_data("identity/group/name/#{name}") + end + + private + + def write_identity(path:, name:, policies:, defaults: {}, extra_params: [], merge_policies: true) + full_path = "#{path}/name/#{name}" + Domain.with_advisory_lock(full_path) do + identity = client.logical.read(full_path) + policies = (policies || []) + (identity&.data&.fetch(:policies) || []) if merge_policies + params = defaults. + merge({ + name: name, + policies: policies.uniq + }). + merge((identity&.data || {}). + slice(*extra_params)). + compact + # cannot supply member ids for external group + if params[:type] == "external" + params.delete(:member_entity_ids) + end + client.logical.write(path, params) + end + end + + def get_identity_data(path) + identity = client.logical.read(path) + if identity + [ identity.data[:policies], identity.data[:metadata] ] + else + [ [], {} ] + end + end + end + end +end diff --git a/app/lib/clients/vault/identity_alias.rb b/app/lib/clients/vault/identity_alias.rb new file mode 100644 index 0000000..de83add --- /dev/null +++ b/app/lib/clients/vault/identity_alias.rb @@ -0,0 +1,80 @@ +module Clients + class Vault + module IdentityAlias + def put_entity_alias(entity_name, alias_name, auth_path) + write_identity_alias("entity", entity_name, alias_name, auth_path) + end + + def put_group_alias(group_name, alias_name, auth_path) + write_identity_alias("group", group_name, alias_name, auth_path) + end + + def read_entity_alias(entity_name, alias_name, auth_path) + read_identity_alias("entity", entity_name, alias_name, auth_path) + end + + def read_group_alias(group_name, alias_name, auth_path) + read_identity_alias("group", group_name, alias_name, auth_path) + end + + def delete_entity_alias(entity_name, alias_name, auth_path) + identity = client.logical.read("identity/entity/name/#{entity_name}") + if identity.nil? + raise "no such #{type} #{identity_name}" + end + id = find_identity_alias_id(identity, alias_name, auth_path) + if id.nil? + raise "no such alias #{alias_name}" + end + client.logical.delete("identity/entity-alias/id/#{id}") + end + + private + + def find_identity_alias_id(identity, alias_name, auth_path) + aliases = identity.data[:aliases] || [ identity.data[:alias] ] + a = find_alias(aliases, alias_name, auth_path) + a&.fetch(:id) + end + + def find_alias(aliases, name, auth_path) + aliases&.find { |a| a[:name] == name && a[:mount_path] == "auth/#{auth_path}/" } + end + + def read_identity_alias(type, identity_name, alias_name, auth_path) + identity = client.logical.read("identity/#{type}/name/#{identity_name}") + if identity.nil? + raise "no such #{type} #{identity_name}" + end + id = find_identity_alias_id(identity, alias_name, auth_path) + if id.nil? + raise "no such alias #{alias_name}" + end + client.logical.read("identity/#{type}-alias/id/#{id}") + end + + def write_identity_alias(type, identity_name, alias_name, auth_path) + auth_sym = "#{auth_path}/".to_sym + accessor = client.logical.read("/sys/auth") + accessor = accessor.data[auth_sym][:accessor] + + identity = client.logical.read("identity/#{type}/name/#{identity_name}") + if identity.nil? + raise "no such #{type} #{identity_name}" + end + aliases = (identity.data[:aliases] || [ identity.data[:alias] ]) + identity_alias = find_alias(aliases, alias_name, auth_path) + # only create alias when not existant + unless identity_alias + client.logical.write("identity/#{type}-alias", + { + name: alias_name, + mount_accessor: accessor, + canonical_id: identity.data[:id] + } + ) + end + end + end + end +end diff --git a/app/lib/clients/vault/key_value.rb b/app/lib/clients/vault/key_value.rb index a43c033..a2b521b 100644 --- a/app/lib/clients/vault/key_value.rb +++ b/app/lib/clients/vault/key_value.rb @@ -4,20 +4,35 @@ module KeyValue extend Policy def kv_read(identity, path) - verify_policy(identity, policy_path(path)) + kv_metadata = KvMetadata.find_by(path: path) + verify_policy(identity, producer_policy_path(path), kv_metadata&.read_groups, consumer_policy_path(path)) client.kv(kv_mount).read(path) end - def kv_write(identity, path, data) - create_kv_policy(path) - assign_policy(identity, policy_path(path)) - client.logical.write("#{kv_mount}/data/#{path}", data: data) + def kv_write(identity, read_groups, path, data) + # only producer can replace existing secret + if client.kv(kv_mount).read(path) + verify_policy(identity, producer_policy_path(path)) + end + + create_kv_policies(path) + assign_entity_policy(identity, producer_policy_path(path)) + assign_groups_policy(read_groups, consumer_policy_path(path)) + secret = client.logical.write("#{kv_mount}/data/#{path}", data: data) + KvMetadata.find_or_create_by(path: path).update(owner: identity.sub, read_groups: read_groups) + secret end def kv_delete(identity, path) - verify_policy(identity, policy_path(path)) + unless client.kv(kv_mount).read(path) + return + end + verify_policy(identity, producer_policy_path(path)) + kv_metadata = KvMetadata.find_by(path: path) client.logical.delete("#{kv_mount}/data/#{path}") - remove_policy(identity, policy_path(path)) + remove_entity_policy(identity, producer_policy_path(path)) + remove_groups_policy((kv_metadata&.read_groups || []), consumer_policy_path(path)) + kv_metadata.destroy! if kv_metadata end def configure_kv @@ -36,21 +51,34 @@ def kv_engine_type "kv-v2" end - def create_kv_policy(path) - client.sys.put_policy(policy_path(path), kv_policy(path)) + def create_kv_policies(path) + client.sys.put_policy(producer_policy_path(path), kv_producer_policy(path)) + client.sys.put_policy(consumer_policy_path(path), kv_consumer_policy(path)) end - def policy_path(path) - "kv_policy/#{path}" + def producer_policy_path(path) + "#{kv_mount}_policy/#{path}/producer" end - def kv_policy(path) + def consumer_policy_path(path) + "#{kv_mount}_policy/#{path}/consumer" + end + + def kv_producer_policy(path) policy = <<-EOH path "#{path}" { capabilities = ["create", "read", "update", "delete"] } EOH end + + def kv_consumer_policy(path) + policy = <<-EOH + path "#{path}" { + capabilities = ["read"] + } + EOH + end end end end diff --git a/app/lib/clients/vault/oidc.rb b/app/lib/clients/vault/oidc.rb index ac1dd36..9ed050e 100644 --- a/app/lib/clients/vault/oidc.rb +++ b/app/lib/clients/vault/oidc.rb @@ -11,7 +11,7 @@ def configure_as_oidc_client(issuer, client_id, client_secret) def configure_oidc_user(name, email, policy) client.sys.put_policy(email, policy) - put_entity(name, email) + put_entity(name, [ email ]) put_entity_alias(name, email, "oidc") end @@ -37,8 +37,10 @@ def create_default_role(client_id) bound_audiences: client_id, allowed_redirect_uris: Config[:oidc_redirect_uris], user_claim: "email", + groups_claim: "groups", oidc_scopes: "email", token_policies: "default") + # add 'verbose_oidc_logging: true` to params for JWT token debugging end def oidc_auth_data diff --git a/app/lib/clients/vault/policy.rb b/app/lib/clients/vault/policy.rb index 61dd38a..fb82509 100644 --- a/app/lib/clients/vault/policy.rb +++ b/app/lib/clients/vault/policy.rb @@ -1,7 +1,8 @@ module Clients class Vault module Policy - extend Entity + extend Identity + extend Oidc def rotate_token create_astral_policy @@ -9,35 +10,72 @@ def rotate_token Clients::Vault.token = token end - def assign_policy(identity, policy_name) + def assign_entity_policy(identity, policy_name) sub = identity.sub email = identity.email Domain.with_advisory_lock(sub) do - policies, metadata = get_entity_data(sub) - policies.append(policy_name).uniq! - put_entity(sub, policies, metadata) + put_entity(sub, [ policy_name ]) put_entity_alias(sub, email, "oidc") end end - def verify_policy(identity, policy_name) + def assign_groups_policy(groups, policy_name) + groups.each do |group| + put_group(group, [ policy_name ]) + put_group_alias(group, group, "oidc") + end + end + + def verify_policy(identity, producer_policy_name, consumer_groups = nil, consumer_policy_name = nil) + # check identity policies sub = identity.sub policies, _ = get_entity_data(sub) - unless policies.any? { |p| p == policy_name } - raise AuthError.new("Policy has not been granted to the identity") + return if (policies || []).any? { |p| p == producer_policy_name } + + # check group membership in consumer policy if given + if consumer_groups.present? && consumer_policy_name.present? + (consumer_groups & identity.groups).each do |group| + policies, _ = get_group_data(group) + return if (policies || []).any? { |p| p == consumer_policy_name } + end end + raise AuthError.new("Policy has not been granted to the identity") end - def remove_policy(identity, policy_name) + def remove_entity_policy(identity, policy_name) sub = identity.sub Domain.with_advisory_lock(sub) do - policies, metadata = get_entity_data(sub) + policies, _ = get_entity_data(sub) policies.reject! { |p| p == policy_name } - put_entity(sub, policies, metadata) + write_identity(path: "identity/entity", + name: sub, + policies: policies, + extra_params: [ :disabled, :metadata ], + merge_policies: false) end client.sys.delete_policy(policy_name) end + def remove_group_policy(group, policy_name) + Domain.with_advisory_lock(group) do + policies, _ = get_group_data(group) + policies.reject! { |p| p == policy_name } + write_identity(path: "identity/group", + name: group, + policies: policies, + extra_params: [ :metadata, :type, :member_group_ids, :member_entity_ids ], + merge_policies: false, + defaults: { type: "external" }) + end + client.sys.delete_policy(policy_name) + end + + def remove_groups_policy(groups, policy_name) + groups.each do |group| + remove_group_policy(group, policy_name) + end + end + private def create_astral_policy @@ -60,6 +98,21 @@ def create_astral_policy path "identity/entity-alias" { capabilities = ["create", "read", "update", "delete", "list"] } + path "identity/entity-alias/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } + path "identity/group" { + capabilities = ["create", "read", "update", "delete", "list"] + } + path "identity/group/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } + path "identity/group-alias" { + capabilities = ["create", "read", "update", "delete", "list"] + } + path "identity/group-alias/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } path "/sys/auth" { capabilities = ["read"] } diff --git a/app/lib/requests/secret_request.rb b/app/lib/requests/secret_request.rb index 2ec6517..9dd98a4 100644 --- a/app/lib/requests/secret_request.rb +++ b/app/lib/requests/secret_request.rb @@ -4,9 +4,14 @@ class SecretRequest include ActiveModel::Attributes attribute :path, :string + attribute :groups, :string attribute :data alias_attribute :kv_path, :path validates :path, presence: true + + def groups_array + (groups || "").split(",").sort.uniq + end end end diff --git a/app/lib/services/key_value.rb b/app/lib/services/key_value.rb index 9bf2ad4..4c2345d 100644 --- a/app/lib/services/key_value.rb +++ b/app/lib/services/key_value.rb @@ -5,8 +5,8 @@ def read(identity, path) impl.kv_read(identity, path) end - def write(identity, path, data) - impl.kv_write(identity, path, data) + def write(identity, read_groups, path, data) + impl.kv_write(identity, read_groups, path, data) end def delete(identity, path) diff --git a/app/lib/utils/oidc_provider.rb b/app/lib/utils/oidc_provider.rb index 0a88ddf..81c057e 100644 --- a/app/lib/utils/oidc_provider.rb +++ b/app/lib/utils/oidc_provider.rb @@ -57,7 +57,8 @@ def create_provider_webapp def create_provider_with_email_scope vault_client.logical.write("identity/oidc/scope/email", - template: '{"email": {{identity.entity.metadata.email}}}') + template: '{"groups": {{identity.entity.groups.names}}, + "email": {{identity.entity.metadata.email}}}') vault_client.logical.write("identity/oidc/provider/astral", issuer: Config[:oidc_provider_addr], allowed_client_ids: @client_id, @@ -90,5 +91,9 @@ def map_userpass_to_entity name: Config[:initial_user_name], canonical_id: entity_id, mount_accessor: accessor) + # setup a group membership for intial user + vault_client.logical.write("identity/group", + name: "read_group", + member_entity_ids: entity_id) end end diff --git a/app/models/domain.rb b/app/models/domain.rb index a7a1ca2..1be391e 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,16 +1,11 @@ class Domain < ApplicationRecord validates :fqdn, presence: true - if Config[:db_encryption] - encrypts :fqdn, :users, :groups - end + serialize :groups, type: Array, coder: JSON + serialize :users, type: Array, coder: JSON - def groups_array - (groups || "").split(",").sort.uniq - end - - def users_array - (users || "").split(",").sort.uniq + if Config[:db_encryption] + encrypts :fqdn, :users, :groups end end diff --git a/app/models/kv_metadata.rb b/app/models/kv_metadata.rb new file mode 100644 index 0000000..7945677 --- /dev/null +++ b/app/models/kv_metadata.rb @@ -0,0 +1,11 @@ +class KvMetadata < ApplicationRecord + validates :path, presence: true + validates :owner, presence: true + + serialize :read_groups, type: Array, coder: JSON + serialize :write_groups, type: Array, coder: JSON + + if Config[:db_encryption] + encrypts :path, :owner, :read_groups, :write_groups + end +end diff --git a/db/migrate/20241114150941_create_kv_metadata.rb b/db/migrate/20241114150941_create_kv_metadata.rb new file mode 100644 index 0000000..c691e83 --- /dev/null +++ b/db/migrate/20241114150941_create_kv_metadata.rb @@ -0,0 +1,11 @@ +class CreateKvMetadata < ActiveRecord::Migration[7.2] + def change + create_table :kv_metadata do |t| + t.string :path, null: false, index: { unique: true } + t.string :owner, null: false + t.string :read_groups, null: true + t.string :write_groups, null: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e14708e..891ccca 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_09_04_175652) do +ActiveRecord::Schema[7.2].define(version: 2024_11_14_150941) do create_table "domains", force: :cascade do |t| t.string "fqdn", null: false t.text "users" @@ -20,4 +20,14 @@ t.datetime "updated_at", null: false t.index ["fqdn"], name: "index_domains_on_fqdn", unique: true end + + create_table "kv_metadata", force: :cascade do |t| + t.string "path", null: false + t.string "owner", null: false + t.string "read_groups" + t.string "write_groups" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["path"], name: "index_kv_metadata_on_path", unique: true + end end diff --git a/db/seeds.rb b/db/seeds.rb index fa0adc1..bc12622 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -10,5 +10,5 @@ # this seed is for development only if Rails.env.development? - Domain.find_or_create_by!(fqdn: "example.com", users: "john.doe@example.com") + Domain.find_or_create_by!(fqdn: "example.com", users: [ "john.doe@example.com" ]) end diff --git a/doc/openapi/paths/secrets.yml b/doc/openapi/paths/secrets.yml index 02ae979..6d194c5 100644 --- a/doc/openapi/paths/secrets.yml +++ b/doc/openapi/paths/secrets.yml @@ -15,6 +15,10 @@ postSecrets: type: string description: "Path where the secret is stored" example: "secret/storage/path" + groups: + type: string + description: "Comma-separated list of OIDC groups allowed to read the secret" + example: "svc-account1,dev-group1" data: type: object description: "The secret data" diff --git a/test/fixtures/domains.yml b/test/fixtures/domains.yml index 82dc778..597353e 100644 --- a/test/fixtures/domains.yml +++ b/test/fixtures/domains.yml @@ -1,16 +1,16 @@ owner_match: fqdn: example.com - users: john.doe@example.com,some.other@example.com + users: '["john.doe@example.com", "some.other@example.com"]' group_delegation: false group_match: fqdn: example2.com - users: some.other@example2.com + users: '["some.other@example2.com"]' group_delegation: true - groups: group1,group2 + groups: '["group1", "group2"]' no_match: fqdn: example3.com - users: some.other@example2.com,yet.another@example2.com + users: '["some.other@example2.com", "yet.another@example2.com"]' group_delegation: true - groups: group3,group4 + groups: '["group3", "group4"]' diff --git a/test/integration/secrets_test.rb b/test/integration/secrets_test.rb index bdb1ac8..546c767 100644 --- a/test/integration/secrets_test.rb +++ b/test/integration/secrets_test.rb @@ -19,6 +19,20 @@ class SecretsTest < ActionDispatch::IntegrationTest end end + test "#update an existing secret with same user is authorized" do + existing_path = create_secret + assert_response :success + create_secret(jwt_authorized, existing_path) + assert_response :success + end + + test "#update an existing secret with a different user is unauthorized" do + existing_path = create_secret + assert_response :success + create_secret(jwt_read_group, existing_path) + assert_response :unauthorized + end + test "#show" do path = create_secret # view the secret @@ -29,6 +43,16 @@ class SecretsTest < ActionDispatch::IntegrationTest end end + test "#show with read_group is authorized" do + path = create_secret + # view the secret + get secret_path(path), headers: { "Authorization" => "Bearer #{jwt_read_group}" } + assert_response :success + %w[ data metadata lease_id ].each do |key| + assert_includes response.parsed_body["secret"].keys, key + end + end + test "#delete" do path = create_secret # delete the secret @@ -36,14 +60,19 @@ class SecretsTest < ActionDispatch::IntegrationTest assert_response :success end + test "#delete with a read-authorized user is unauthorized" do + path = create_secret + # delete the secret + delete destroy_secret_path(path), headers: { "Authorization" => "Bearer #{jwt_read_group}" } + assert_response :unauthorized + end + private - def create_secret - # make a path - path = "top/secret/#{SecureRandom.hex}" + def create_secret(jwt = jwt_authorized, path = "top/secret/#{SecureRandom.hex}") # create the secret - post secrets_path, headers: { "Authorization" => "Bearer #{jwt_authorized}" }, - params: { secret: { path: path, data: { password: "sicr3t" } } } + post secrets_path, headers: { "Authorization" => "Bearer #{jwt}" }, + params: { secret: { path: path, data: { password: "sicr3t" }, groups: "read_group" } } path end diff --git a/test/interactors/application_interactor_test.rb b/test/interactors/application_interactor_test.rb index 42a97ac..b189059 100644 --- a/test/interactors/application_interactor_test.rb +++ b/test/interactors/application_interactor_test.rb @@ -3,7 +3,7 @@ class ApplicationInteractorTest < ActiveSupport::TestCase def setup @domain = domains(:owner_match) - @identity = Identity.new(subject: @domain.users_array.first) + @identity = Identity.new(subject: @domain.users.first) @cr = Requests::CertIssueRequest.new(common_name: @domain.fqdn) @log = Tempfile.new("log-test") Config[:audit_log_file] = @log.path diff --git a/test/interactors/authorize_cert_request_test.rb b/test/interactors/authorize_cert_request_test.rb index 6ed51a6..b54aa28 100644 --- a/test/interactors/authorize_cert_request_test.rb +++ b/test/interactors/authorize_cert_request_test.rb @@ -3,7 +3,7 @@ class AuthorizeCertRequestTest < ActiveSupport::TestCase def setup @domain = domains(:group_match) - @identity = Identity.new(subject: @domain.users_array.first) + @identity = Identity.new(subject: @domain.users.first) @cr = Requests::CertIssueRequest.new(common_name: @domain.fqdn) @interactor = AuthorizeCertRequest end @@ -21,14 +21,14 @@ def setup end test ".call with matching group" do - @domain.update(users: "different_owner@example.com") - @identity.groups = [ @domain.groups_array.first ] + @domain.update(users: [ "different_owner@example.com" ]) + @identity.groups = [ @domain.groups.first ] rslt = @interactor.call(identity: @identity, request: @cr) assert rslt.success? end test ".call with non-matching group" do - @domain.update(users: "different_owner@example.com") + @domain.update(users: [ "different_owner@example.com" ]) @identity.groups = [ "different_group" ] rslt = @interactor.call(identity: @identity, request: @cr) assert_not rslt.success? diff --git a/test/interactors/refresh_domain_test.rb b/test/interactors/refresh_domain_test.rb index 167b317..5477974 100644 --- a/test/interactors/refresh_domain_test.rb +++ b/test/interactors/refresh_domain_test.rb @@ -3,7 +3,7 @@ class RefreshDomainTest < ActiveSupport::TestCase def setup @domain = domains(:owner_match) - @identity = Identity.new(subject: @domain.users_array.first) + @identity = Identity.new(subject: @domain.users.first) @cr = Requests::CertIssueRequest.new(common_name: @domain.fqdn) @interactor = RefreshDomain end diff --git a/test/lib/clients/app_registry_test.rb b/test/lib/clients/app_registry_test.rb index 8e57557..e72f8d7 100644 --- a/test/lib/clients/app_registry_test.rb +++ b/test/lib/clients/app_registry_test.rb @@ -8,8 +8,8 @@ class AppRegistryTest < ActiveSupport::TestCase test "#get_domain_info fetches from configured api server" do domain_info = @client.get_domain_info(domains(:owner_match).fqdn) assert_not_nil domain_info - assert_equal "group1", domain_info.groups - assert_equal "john.doe@example.com", domain_info.users + assert_equal [ "group1" ], domain_info.groups + assert_equal [ "john.doe@example.com" ], domain_info.users assert_equal "example.com", domain_info.fqdn assert domain_info.group_delegation end diff --git a/test/lib/clients/vault/identity_alias_test.rb b/test/lib/clients/vault/identity_alias_test.rb new file mode 100644 index 0000000..e7fd913 --- /dev/null +++ b/test/lib/clients/vault/identity_alias_test.rb @@ -0,0 +1,74 @@ +require "test_helper" + +class IdentityAliasTest < ActiveSupport::TestCase + setup do + @client = Clients::Vault + @identity = Identity.new + email = SecureRandom.hex(4) + @identity.sub = email + @alias_name = @identity.sub + @group_name = SecureRandom.hex(4) + @policies = %w[ my_policy1 my_policy2 ] + @auth_path = "oidc" + end + + test "#put_entity_alias creates an entity_alias" do + assert_raise { @client.read_entity_alias(@identity.sub, @alias_name, @auth_path) } + @client.put_entity(@identity.sub, @policies) + + assert_kind_of Vault::Secret, @client.put_entity_alias(@identity.sub, @alias_name, @auth_path) + entity_alias = @client.read_entity_alias(@identity.sub, @alias_name, @auth_path) + assert_not_nil entity_alias + end + + test "#put_entity_alias skips an existing entity_alias" do + existing_alias = SecureRandom.hex + assert_raise { @client.read_entity_alias(@identity.sub, existing_alias, @auth_path) } + @client.put_entity(@identity.sub, @policies) + assert_kind_of Vault::Secret, @client.put_entity_alias(@identity.sub, existing_alias, @auth_path) + entity_alias = @client.read_entity_alias(@identity.sub, existing_alias, @auth_path) + assert_not_nil entity_alias + + # returns nil/no error when an existing alias exists + assert_nil @client.put_entity_alias(@identity.sub, existing_alias, @auth_path) + entity_alias = @client.read_entity_alias(@identity.sub, existing_alias, @auth_path) + assert_not_nil entity_alias + end + + test "#delete_entity_alias removes an entity_alias" do + @client.put_entity(@identity.sub, @policies) + + assert_kind_of Vault::Secret, @client.put_entity_alias(@identity.sub, @alias_name, @auth_path) + entity_alias = @client.read_entity_alias(@identity.sub, @alias_name, @auth_path) + assert_not_nil entity_alias + + @client.delete_entity_alias(@identity.sub, @alias_name, @auth_path) + assert_raise { @client.read_entity_alias(@identity.sub, @alias_name, @auth_path) } + end + + test "#put_group_alias creates a group_alias" do + assert_raise { @client.read_group_alias(@group_name, @alias_name, @auth_path) } + @client.put_group(@group_name, @policies) + + assert_kind_of Vault::Secret, @client.put_group_alias(@group_name, @alias_name, @auth_path) + group_alias = @client.read_group_alias(@group_name, @alias_name, @auth_path) + assert_not_nil group_alias + end + + test "#put_group_alias skips an existing group_alias" do + existing_alias = SecureRandom.hex + assert_raise { @client.read_group_alias(@group_name, existing_alias, @auth_path) } + @client.put_group(@group_name, @policies) + assert_kind_of Vault::Secret, @client.put_group_alias(@group_name, existing_alias, @auth_path) + group_alias = @client.read_group_alias(@group_name, existing_alias, @auth_path) + assert_not_nil group_alias + + # returns nil/no error when an existing alias exists + assert_nil @client.put_group_alias(@group_name, existing_alias, @auth_path) + group_alias = @client.read_group_alias(@group_name, existing_alias, @auth_path) + assert_not_nil group_alias + # verify alias belongs to the group + group = @client.read_group(@group_name) + assert_equal group_alias.to_h[:data][:canonical_id], group.data[:alias][:canonical_id] + end +end diff --git a/test/lib/clients/vault/identity_test.rb b/test/lib/clients/vault/identity_test.rb new file mode 100644 index 0000000..1460136 --- /dev/null +++ b/test/lib/clients/vault/identity_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class IdentityTest < ActiveSupport::TestCase + setup do + @client = Clients::Vault + @identity = Identity.new + email = SecureRandom.hex(4) + @identity.sub = email + @group_name = SecureRandom.hex(4) + @policies = %w[ my_policy1 my_policy2 ] + end + + test "#put_entity creates an entity" do + entity = @client.read_entity(@identity.sub) + assert_nil entity + + @client.put_entity(@identity.sub, @policies) + entity = @client.read_entity(@identity.sub) + assert_equal @policies, entity.data[:policies] + end + + test "#put_entity merges policies for an existing entity" do + existing_policies = %w[ policy_from_elsewhere ] + existing_entity = SecureRandom.hex(4) + + @client.put_entity(existing_entity, existing_policies) + policies, metadata = @client.get_entity_data(existing_entity) + assert_equal existing_policies, policies + + @client.put_entity(existing_entity, @policies) + policies, metadata = @client.get_entity_data(existing_entity) + assert_equal @policies + existing_policies, policies + end + + test "#delete_entity removes an entity" do + @client.put_entity(@identity.sub, @policies) + @client.delete_entity(@identity.sub) + entity = @client.read_entity(@identity.sub) + assert_nil entity + end + + test "#put_group creates an group" do + policies, metadata = @client.get_group_data(@group_name) + assert_empty policies + + @client.put_group(@group_name, @policies) + policies, metadata = @client.get_group_data(@group_name) + assert_equal @policies, policies + end + + test "#put_group merges policies for an existing group" do + existing_policies = %w[ policy_from_elsewhere ] + existing_group = SecureRandom.hex(4) + + @client.put_group(existing_group, existing_policies) + policies, metadata = @client.get_group_data(existing_group) + assert_equal existing_policies, policies + + @client.put_group(existing_group, @policies) + policies, metadata = @client.get_group_data(existing_group) + assert_equal @policies + existing_policies, policies + end + + test "#put_group retains existing fields (member_entity_ids, type, metadata, etc)" do + existing_policies = %w[ policy_from_elsewhere ] + existing_group = SecureRandom.hex(4) + existing_metadata = { existing_md: "some value" } + entity = @client.put_entity(@identity.sub, @policies) + + params = { + policies: existing_policies, + metadata: existing_metadata, + member_entity_ids: [ entity.data[:id] ], + type: "internal" + } + write_identity( + path: "identity/group", + name: existing_group, + params: params + ) + group = @client.read_group(existing_group) + assert_equal existing_policies, group.data[:policies] + + @client.put_group(existing_group, @policies) + group = @client.read_group(existing_group) + assert_equal existing_metadata, group.data[:metadata] + assert_equal [ entity.data[:id] ], group.data[:member_entity_ids] + assert_equal "internal", group.data[:type] + end + + private + + def write_identity(path:, name:, params:) + full_path = "#{path}/name/#{name}" + @client.send(:client).logical.write(full_path, params) + end +end diff --git a/test/lib/clients/vault/policy_test.rb b/test/lib/clients/vault/policy_test.rb new file mode 100644 index 0000000..c60c19d --- /dev/null +++ b/test/lib/clients/vault/policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class PolicyTest < ActiveSupport::TestCase + setup do + @client = Clients::Vault + @identity = Identity.new + email = SecureRandom.hex(4) + @identity.sub = email + end + + test "#verify_policy raises when identity does not have the policy" do + policy_name = "some/policy/name" + @client.expects(:get_entity_data).with(@identity.sub).returns([ [ "some/other/policy" ], nil ]) + err = assert_raises { @client.verify_policy(@identity, policy_name) } + assert_kind_of AuthError, err + end + + test "#verify_policy permits identity having the policy" do + policy_name = "some/policy/name" + @client.expects(:get_entity_data).with(@identity.sub).returns([ [ policy_name ], nil ]) + assert_nil @client.verify_policy(@identity, policy_name) + end + + test "#verify_policy checks identity.groups that overlap with supplied groups for the policy" do + producer_policy = "some/policy/name" + consumer_policy = "some/policy/other" + @identity.groups = [ "my-group", "some-other-group" ] + @client.expects(:get_entity_data).with(@identity.sub).returns([ [], nil ]) + @client.expects(:get_group_data).with("my-group").returns([ [], {} ]) + err = assert_raises { @client.verify_policy(@identity, producer_policy, [ "my-group", "yet-another-group" ], consumer_policy) } + assert_kind_of AuthError, err + end + + test "#verify_policy permits identity when one overlapping group (identity.groups and supplied groups) has the policy" do + producer_policy = "some/policy/name" + consumer_policy = "some/policy/other" + @identity.groups = [ "my-group" ] + @client.expects(:get_entity_data).with(@identity.sub).returns([ [], nil ]) + @client.expects(:get_group_data).with("my-group").returns([ [ consumer_policy ], {} ]) + assert_nil @client.verify_policy(@identity, producer_policy, [ "my-group" ], consumer_policy) + end +end diff --git a/test/lib/clients/vault_test.rb b/test/lib/clients/vault_test.rb index fe1c8f0..fe4ed3a 100644 --- a/test/lib/clients/vault_test.rb +++ b/test/lib/clients/vault_test.rb @@ -15,7 +15,7 @@ class VaultTest < ActiveSupport::TestCase @root_ca_mount = SecureRandom.hex(4) @intermediate_ca_mount = SecureRandom.hex(4) @kv_mount = SecureRandom.hex(4) - @policies = SecureRandom.hex(4) + @policies = [ SecureRandom.hex(4) ] @entity_name = SecureRandom.hex(4) @alias_name = SecureRandom.hex(4) @identity = Identity.new @@ -64,11 +64,13 @@ class VaultTest < ActiveSupport::TestCase test ".rotate_token" do # begins with default token assert_equal vault_token, @client.token - assert @client.rotate_token # now has a new token + assert @client.rotate_token assert_not_equal vault_token, @client.token # ensure we can write with the new token - assert_instance_of Vault::Secret, @client.kv_write(@identity, "testing/secret", { password: "sicr3t" }) + kv_path = "testing/#{SecureRandom.hex}" + assert_instance_of Vault::Secret, @client.kv_write(@identity, [], kv_path, { password: "sicr3t" }) + assert @client.kv_delete(@identity, kv_path) end test "entity methods" do @@ -77,7 +79,7 @@ class VaultTest < ActiveSupport::TestCase @client.put_entity(@entity_name, @policies) entity = @client.read_entity(@entity_name) - assert_equal @policies, entity.data[:policies][0] + assert_equal @policies, entity.data[:policies] @client.delete_entity(@entity_name) entity = @client.read_entity(@entity_name) @@ -87,7 +89,7 @@ class VaultTest < ActiveSupport::TestCase test "kv methods" do # check kv_write path = "test/path/#{SecureRandom.hex}" - secret = @client.kv_write(@identity, path, { data: "data" }) + secret = @client.kv_write(@identity, [ "group_can_read" ], path, { data: "data" }) assert_kind_of Vault::Secret, secret # check kv_read @@ -96,15 +98,22 @@ class VaultTest < ActiveSupport::TestCase # check policy is created entity = @client.read_entity(@identity.sub) - assert_includes entity.data[:policies], "kv_policy/#{path}" + assert_includes entity.data[:policies], "kv_astral_policy/#{path}/producer" - # check kv_read denied to other identity + # check kv_read denied to other identity by default alt_identity = Identity.new alt_identity.sub = SecureRandom.hex(4) - err = assert_raises { @client.kv_read(alt_identity, path) } + err = assert_raises do + @client.kv_read(alt_identity, path) + end assert_kind_of AuthError, err - # check kv_delete denied to other identity + # check kv_read permitted to other identity with group membership + alt_identity.groups = [ "group_can_read" ] + group_read_secret = @client.kv_read(alt_identity, path) + assert_kind_of Vault::Secret, group_read_secret + + # check kv_delete denied to other identity even with group err = assert_raises { @client.kv_delete(alt_identity, path) } assert_kind_of AuthError, err @@ -113,7 +122,7 @@ class VaultTest < ActiveSupport::TestCase assert del_secret # check policy is removed entity = @client.read_entity(@identity.sub) - assert_not_includes entity.data[:policies], "kv_policy/#{path}" + assert_not_includes entity.data[:policies], "kv_astral_policy/#{path}" err = assert_raises { @client.kv_read(@identity, path) } assert_kind_of AuthError, err end @@ -161,8 +170,8 @@ class VaultTest < ActiveSupport::TestCase assert_equal 1, entity.data[:aliases].size end - test ".assign_policy creates valid entity" do - @client.assign_policy(@identity, "test_path") + test ".assign_entity_policy creates valid entity" do + @client.assign_entity_policy(@identity, "test_path") entity = @client.read_entity(@identity.sub) assert entity.data[:policies].any? { |p| p == "test_path" } diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index ec8f8c1..f822423 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -5,7 +5,7 @@ class DomainTest < ActiveSupport::TestCase def setup @attributes = { fqdn: "example4.com", - users: "john.doe@example.com" + users: [ "john.doe@example.com" ] } @domain = Domain.new(@attributes) end @@ -25,14 +25,4 @@ def setup assert_not @domain.valid? assert_includes @domain.errors[:fqdn], "can't be blank" end - - test "#groups_array should convert to array, sort, and dedupe groups" do - @domain.groups = "two,two,one" - assert_equal [ "one", "two" ], @domain.groups_array - end - - test "#users_array should convert to array, sort, and dedupe users" do - @domain.users = "two,two,one" - assert_equal [ "one", "two" ], @domain.users_array - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index efec512..ad9629b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,8 @@ require_relative "../config/environment" require "rails/test_help" require "minitest/mock" +require "minitest/spec" +require "mocha/minitest" module ActiveSupport class TestCase @@ -20,9 +22,16 @@ def jwt_unauthorized @@unauthorized_token ||= JWT.encode(@@unauthorized_data, "bad_secret") end + def jwt_read_group + @@read_group_token ||= JWT.encode(@@read_group_data, Config[:jwt_signing_key]) + end + private + @@authorized_data = { "sub"=>"john.doe@example.com", "name"=>"John Doe", "iat"=>1516239022, - "groups"=>[ "group1", "group2" ], "aud"=>"astral" } + "groups"=>[ "group1", "group2" ], "aud"=>"astral" } @@unauthorized_data = { "sub"=>"application_name", "common_name"=>"example.com", "ip_sans"=>"10.0.1.100" } + @@read_group_data = { "sub"=>"exene.cervenka@example.com", "name"=>"Exene Cervenka", "iat"=>1516239022, + "groups"=>[ "read_group" ], "aud"=>"astral" } end end