diff --git a/Dockerfile b/Dockerfile index 9713604..e5ec729 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,66 +1,58 @@ # syntax = docker/dockerfile:1 - -# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: -# docker build -t my-app . -# docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY= my-app - -# Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.3.4 -FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base +ARG RAILS_ROOT=/app +FROM ruby:$RUBY_VERSION-alpine AS builder -# Rails app lives here -WORKDIR /rails - -# Install base packages -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libjemalloc2 libsqlite3-0 libvips && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives +# Install build dependencies +RUN apk add --no-cache build-base git pkgconfig # Set production environment ENV RAILS_ENV="production" \ + RAILS_ROOT="/app" \ BUNDLE_DEPLOYMENT="1" \ - BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" - -# Throw-away build stage to reduce size of final image -FROM base AS build + BUNDLE_PATH="/app/.bundle" \ + BUNDLE_WITHOUT="test development" -# Install packages needed to build gems -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git pkg-config && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives +WORKDIR $RAILS_ROOT -# Install application gems +# Install gems COPY Gemfile Gemfile.lock ./ -RUN bundle install && \ - rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ - bundle exec bootsnap precompile --gemfile +RUN bundle config --global frozen 1 \ + && bundle config set path 'vendor/bundle' \ + && bundle install --without development:test -j4 --retry 3 \ + && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem # \ + && find vendor/bundle/ruby/3.3.0/gems/ -name "*.c" -delete \ + && find vendor/bundle/ruby/3.3.0/gems/ -name "*.o" -delete # Copy application code COPY . . -# Precompile bootsnap code for faster boot times +# Precompile bootsnap RUN bundle exec bootsnap precompile app/ lib/ +# Final stage +FROM ruby:$RUBY_VERSION-alpine +# Install runtime dependencies +RUN apk add --no-cache curl jemalloc sqlite-libs vips tzdata +ENV RAILS_ENV="production" \ + RAILS_ROOT="/app" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_APP_CONFIG="/app/.bundle" \ + BUNDLE_WITHOUT="test development" -# Final stage for app image -FROM base +WORKDIR $RAILS_ROOT -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails +# Copy built artifacts +COPY --from=builder $RAILS_ROOT $RAILS_ROOT -# Run and own only the runtime files as a non-root user for security -RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ +# Add non-root user +RUN addgroup -S rails && adduser -S rails -G rails && \ chown -R rails:rails db log storage tmp -USER 1000:1000 - -# Entrypoint prepares the database. -ENTRYPOINT ["/rails/bin/docker-entrypoint"] +USER rails:rails # Start the server by default, this can be overwritten at runtime EXPOSE 3000 -CMD ["./bin/rails", "server"] +# Start the server +CMD ["bin/rails", "server", "-b", "0.0.0.0"] \ No newline at end of file diff --git a/Gemfile b/Gemfile index a8e16e9..434ae8a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 7.2.0" +gem "rails", "~> 7.2.1" # Use sqlite3 as the database for Active Record gem "sqlite3", ">= 1.4" # Use the Puma web server [https://github.com/puma/puma] diff --git a/Gemfile.lock b/Gemfile.lock index aefbe81..1754acb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,29 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.2.0) - actionpack (= 7.2.0) - activesupport (= 7.2.0) + actioncable (7.2.1) + actionpack (= 7.2.1) + activesupport (= 7.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.0) - actionpack (= 7.2.0) - activejob (= 7.2.0) - activerecord (= 7.2.0) - activestorage (= 7.2.0) - activesupport (= 7.2.0) + actionmailbox (7.2.1) + actionpack (= 7.2.1) + activejob (= 7.2.1) + activerecord (= 7.2.1) + activestorage (= 7.2.1) + activesupport (= 7.2.1) mail (>= 2.8.0) - actionmailer (7.2.0) - actionpack (= 7.2.0) - actionview (= 7.2.0) - activejob (= 7.2.0) - activesupport (= 7.2.0) + actionmailer (7.2.1) + actionpack (= 7.2.1) + actionview (= 7.2.1) + activejob (= 7.2.1) + activesupport (= 7.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.0) - actionview (= 7.2.0) - activesupport (= 7.2.0) + actionpack (7.2.1) + actionview (= 7.2.1) + activesupport (= 7.2.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4, < 3.2) @@ -32,35 +32,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.0) - actionpack (= 7.2.0) - activerecord (= 7.2.0) - activestorage (= 7.2.0) - activesupport (= 7.2.0) + actiontext (7.2.1) + actionpack (= 7.2.1) + activerecord (= 7.2.1) + activestorage (= 7.2.1) + activesupport (= 7.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.0) - activesupport (= 7.2.0) + actionview (7.2.1) + activesupport (= 7.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.0) - activesupport (= 7.2.0) + activejob (7.2.1) + activesupport (= 7.2.1) globalid (>= 0.3.6) - activemodel (7.2.0) - activesupport (= 7.2.0) - activerecord (7.2.0) - activemodel (= 7.2.0) - activesupport (= 7.2.0) + activemodel (7.2.1) + activesupport (= 7.2.1) + activerecord (7.2.1) + activemodel (= 7.2.1) + activesupport (= 7.2.1) timeout (>= 0.4.0) - activestorage (7.2.0) - actionpack (= 7.2.0) - activejob (= 7.2.0) - activerecord (= 7.2.0) - activesupport (= 7.2.0) + activestorage (7.2.1) + actionpack (= 7.2.1) + activejob (= 7.2.1) + activerecord (= 7.2.1) + activesupport (= 7.2.1) marcel (~> 1.0) - activesupport (7.2.0) + activesupport (7.2.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -114,7 +114,7 @@ GEM net-smtp marcel (1.0.4) mini_mime (1.1.5) - minitest (5.24.1) + minitest (5.25.1) msgpack (1.7.2) net-imap (0.4.14) date @@ -155,20 +155,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.2.0) - actioncable (= 7.2.0) - actionmailbox (= 7.2.0) - actionmailer (= 7.2.0) - actionpack (= 7.2.0) - actiontext (= 7.2.0) - actionview (= 7.2.0) - activejob (= 7.2.0) - activemodel (= 7.2.0) - activerecord (= 7.2.0) - activestorage (= 7.2.0) - activesupport (= 7.2.0) + rails (7.2.1) + actioncable (= 7.2.1) + actionmailbox (= 7.2.1) + actionmailer (= 7.2.1) + actionpack (= 7.2.1) + actiontext (= 7.2.1) + actionview (= 7.2.1) + activejob (= 7.2.1) + activemodel (= 7.2.1) + activerecord (= 7.2.1) + activestorage (= 7.2.1) + activesupport (= 7.2.1) bundler (>= 1.15.0) - railties (= 7.2.0) + railties (= 7.2.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -176,9 +176,9 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.2.0) - actionpack (= 7.2.0) - activesupport (= 7.2.0) + railties (7.2.1) + actionpack (= 7.2.1) + activesupport (= 7.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -272,7 +272,7 @@ DEPENDENCIES debug jwt puma (>= 5.0) - rails (~> 7.2.0) + rails (~> 7.2.1) rubocop-rails-omakase sqlite3 (>= 1.4) tzinfo-data diff --git a/README.md b/README.md index 85318bf..03ab0fa 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ rails s 3) POST /certificates to acquire cert in another terminal (need to provide `common_name` param): ``` curl -X POST http://localhost:3000/certificates \ --H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHBsaWNhdGlvbl9uYW1lIiwiY29tbW9uX25hbWUiOiJleGFtcGxlLmNvbSIsImlwX3NhbnMiOiIxMC4wLjEuMTAwIn0.61e0oQIj7vwGtOpFuPJDCI_Bqf8ZTpJxe_2kUwcbN7Y" \ --H "Content-type: application/json" -d '{ "common_name": "example.com" }' +-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huLmRvZUBleGFtcGxlLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMiwiZ3JvdXBzIjpbImdyb3VwMSIsImdyb3VwMiJdLCJhdWQiOiJhc3RyYWwifQ.tfRLXmE_eq-piP88_clwPWrYfMAQbCJAeZQI6OFxZSI" \ +-H "Content-type: application/json" \ +-d "{ \"common_name\": \"example.com\" }" ``` 4) Run the tests from devcontainer terminal: ``` diff --git a/app/lib/services/app_registry_service.rb b/app/lib/services/app_registry_service.rb deleted file mode 100644 index 6b92cc2..0000000 --- a/app/lib/services/app_registry_service.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Services - class AppRegistryService - def authenticate!(token) - identity = decode(token) - raise AuthError unless identity - # TODO verify identity with authority? - identity - end - - def authorize!(identity, cert_req) - cert_req.fqdns.each do |fqdn| - domain = get_domain_name(fqdn) - raise AuthError unless (domain[:auto_approved_groups] & identity[:groups]).any? - end - end - - private - - def decode(token) - # Decode a JWT access token using the configured base. - body = JWT.decode(token, Rails.application.config.astral[:jwt_signing_key])[0] - HashWithIndifferentAccess.new body - rescue => e - Rails.logger.warn "Unable to decode token: #{e}" - nil - end - - def get_domain_name(fqdn) - # TODO implement - end - end -end diff --git a/app/lib/services/auth_service.rb b/app/lib/services/auth_service.rb index a1e1764..2d1bd7b 100644 --- a/app/lib/services/auth_service.rb +++ b/app/lib/services/auth_service.rb @@ -1,16 +1,29 @@ module Services class AuthService def initialize - # TODO make this selectable - @impl = AppRegistryService.new + @domain_ownership_service = DomainOwnershipService.new end def authenticate!(token) - @impl.authenticate!(token) + identity = decode(token) + raise AuthError unless identity + # TODO verify identity with authority? + identity end - def authorize!(token, cert_issue_req) - @impl.authorize!(token, cert_issue_req) + def authorize!(identity, cert_issue_req) + @domain_ownership_service.authorize!(identity, cert_issue_req) + end + + private + + def decode(token) + # Decode a JWT access token using the configured base. + body = JWT.decode(token, Rails.application.config.astral[:jwt_signing_key])[0] + Identity.new(body) + rescue => e + Rails.logger.warn "Unable to decode token: #{e}" + nil end end end diff --git a/app/lib/services/domain_ownership_service.rb b/app/lib/services/domain_ownership_service.rb new file mode 100644 index 0000000..69ee9c6 --- /dev/null +++ b/app/lib/services/domain_ownership_service.rb @@ -0,0 +1,19 @@ +module Services + class DomainOwnershipService + def authorize!(identity, cert_req) + cert_req.fqdns.each do |fqdn| + domain = get_domain_name(fqdn) + raise AuthError unless domain.owner == identity.subject || + (domain.group_delegation && + (domain.groups & identity.groups).any?) + end + nil + end + + private + + def get_domain_name(fqdn) + # TODO implement + end + end +end diff --git a/app/models/domain_info.rb b/app/models/domain_info.rb new file mode 100644 index 0000000..ce724d5 --- /dev/null +++ b/app/models/domain_info.rb @@ -0,0 +1,8 @@ +class DomainInfo + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :owner, :string + attribute :groups, array: :string, default: [] + attribute :group_delegation, :boolean, default: false +end diff --git a/app/models/identity.rb b/app/models/identity.rb new file mode 100644 index 0000000..20c7491 --- /dev/null +++ b/app/models/identity.rb @@ -0,0 +1,13 @@ +class Identity + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :subject, :string + attribute :name, :string + attribute :iat, :integer + attribute :aud, :string + attribute :groups, array: :string, default: [] + + alias_attribute :sub, :subject + alias_attribute :roles, :groups +end diff --git a/test/integration/certificates_controller_test.rb b/test/integration/certificates_controller_test.rb index 3df4e5e..b21e156 100644 --- a/test/integration/certificates_controller_test.rb +++ b/test/integration/certificates_controller_test.rb @@ -1,19 +1,19 @@ require "test_helper" class CertificatesControllerTest < ActionDispatch::IntegrationTest - test "create unauthorized" do + test "#create unauthorized" do post certificates_path assert_response :unauthorized end - test "create with faulty token (encoded with different signing key)" do + test "#create with faulty token (encoded with different signing key)" do jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHBsaWNhdGlvbl9uYW1lIiwiY29tbW9uX25hbWUiOiJleGFtcGxlLmNvbSIsImlwX3NhbnMiOiIxMC4wLjEuMTAwIn0.gEUyaZcARiBQNq2RUwZU0MdFXqthyo_oSQ8DAgKvxCs" post certificates_path, headers: { "Authorization" => "Bearer #{jwt}" } assert_response :unauthorized end - test "create authorized" do - jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHBsaWNhdGlvbl9uYW1lIiwiY29tbW9uX25hbWUiOiJleGFtcGxlLmNvbSIsImlwX3NhbnMiOiIxMC4wLjEuMTAwIn0.61e0oQIj7vwGtOpFuPJDCI_Bqf8ZTpJxe_2kUwcbN7Y" + test "#create authorized" do + jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huLmRvZUBleGFtcGxlLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMiwiZ3JvdXBzIjpbImdyb3VwMSIsImdyb3VwMiJdLCJhdWQiOiJhc3RyYWwifQ.tfRLXmE_eq-piP88_clwPWrYfMAQbCJAeZQI6OFxZSI" post certificates_path, headers: { "Authorization" => "Bearer #{jwt}" }, params: { common_name: "example.com" } assert_response :success diff --git a/test/lib/services/domain_ownership_service_test.rb b/test/lib/services/domain_ownership_service_test.rb new file mode 100644 index 0000000..3abe508 --- /dev/null +++ b/test/lib/services/domain_ownership_service_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class DomainOwnershipServiceTest < ActiveSupport::TestCase + def setup + @identity = Identity.new(subject: "test@example.com", groups: [ "admin_group" ]) + @domain = DomainInfo.new(owner: "test@example.com", group_delegation: false, groups: [ "admin_group" ]) + end + + test "#authorize! with matching owner" do + ds = Services::DomainOwnershipService.new + ds.stub :get_domain_name, @domain do + assert_nil(ds.authorize!(@identity, CertIssueRequest.new)) + end + end + + test "#authorize! with non-matching owner" do + ds = Services::DomainOwnershipService.new + @domain.owner = "different_owner@example.com" + ds.stub :get_domain_name, @domain do + assert_raises(AuthError) do + ds.authorize!(@identity, CertIssueRequest.new) + end + end + end + + test "#authorize! with matching group" do + ds = Services::DomainOwnershipService.new + @domain.owner = "different_owner@example.com" + @domain.group_delegation = true + ds.stub :get_domain_name, @domain do + assert_nil(ds.authorize!(@identity, CertIssueRequest.new)) + end + end + + test "#authorize! with non-matching group" do + ds = Services::DomainOwnershipService.new + @domain.owner = "different_owner@example.com" + @identity.groups = [ "different_group" ] + ds.stub :get_domain_name, @domain do + assert_raises(AuthError) do + ds.authorize!(@identity, CertIssueRequest.new) + end + end + end +end diff --git a/test/models/cert_isssue_request_test.rb b/test/models/cert_isssue_request_test.rb index 8738915..5ab1363 100644 --- a/test/models/cert_isssue_request_test.rb +++ b/test/models/cert_isssue_request_test.rb @@ -24,35 +24,35 @@ def setup @cert_issue_request = CertIssueRequest.new(@attributes) end - test "should set attributes correctly" do + test "#new should set attributes from attributes argument" do @attributes.each do |key, value| assert_equal value, @cert_issue_request.send(key), "Attribute #{key} was not set correctly" end end - test "should be valid with valid attributes" do + test "#valid? should be valid with valid attributes" do assert @cert_issue_request.valid? end - test "should require a common_name" do + test "#valid? should require a common_name" do @cert_issue_request.common_name = nil assert_not @cert_issue_request.valid? assert_includes @cert_issue_request.errors[:common_name], "can't be blank" end - test "should require a valid format" do + test "#valid? should require a valid format" do @cert_issue_request.format = "invalid_format" assert_not @cert_issue_request.valid? assert_includes @cert_issue_request.errors[:format], "is not included in the list" end - test "should require a valid private_key_format" do + test "#valid? should require a valid private_key_format" do @cert_issue_request.private_key_format = "invalid_format" assert_not @cert_issue_request.valid? assert_includes @cert_issue_request.errors[:private_key_format], "is not included in the list" end - test "should have default values" do + test "#new should have default values" do @cert_issue_request = CertIssueRequest.new assert_equal false, @cert_issue_request.exclude_cn_from_sans assert_equal "pem", @cert_issue_request.format @@ -65,13 +65,12 @@ def setup assert_equal true, @cert_issue_request.server_flag end - test "should be invalid with default values" do + test "#valid? should be false with default values" do @cert_issue_request = CertIssueRequest.new assert_not @cert_issue_request.valid? end - - test "fqdns should return alt_names plus common_name" do + test "#fqdns should return alt_names plus common_name" do assert_equal [ "alt1.example.com", "alt2.example.com", "example.com" ], @cert_issue_request.fqdns end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..c89aa89 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,8 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" +require "minitest" +require "minitest/mock" module ActiveSupport class TestCase