diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb new file mode 100644 index 000000000..fddf6b0eb --- /dev/null +++ b/app/controllers/api/api_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + class ApiController < ActionController::API + def verify_token! + token = request.headers['Authorization']&.remove('Bearer ') + + unless ActiveSupport::SecurityUtils.secure_compare( + token.to_s, + ENV.fetch('OPENHPI_API_TOKEN', 'test_token') + ) + head :unauthorized + end + end + end +end diff --git a/app/controllers/api/internal/users/deletions_controller.rb b/app/controllers/api/internal/users/deletions_controller.rb new file mode 100644 index 000000000..e2b6057e0 --- /dev/null +++ b/app/controllers/api/internal/users/deletions_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module Internal + module Users + class DeletionsController < Api::ApiController + before_action :verify_token! + + def delete + user = ExternalUser.where(external_id: params[:user_id], consumer_id: 1).first + + if user.nil? + return head :not_found + end + + user.soft_delete! + head :ok + rescue StandardError => e + Rails.logger.error("Failed to delete user #{params[:user_id]}: #{e.message}") + head :internal_server_error + end + end + end + end +end diff --git a/app/models/external_user.rb b/app/models/external_user.rb index 323ae9ab5..f59cd3dfc 100644 --- a/app/models/external_user.rb +++ b/app/models/external_user.rb @@ -8,6 +8,10 @@ def displayname name.presence || "#{model_name.human} #{id}" end + def soft_delete! + update(name: 'Deleted User', email: nil) + end + def webauthn_name "#{consumer.name}: #{displayname}" end diff --git a/config/routes.rb b/config/routes.rb index 9a5e8b154..ae179357f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,6 +193,14 @@ mount ActionCable.server => '/cable' mount RailsAdmin::Engine => '/rails_admin', as: 'rails_admin' + namespace :api do + namespace :internal do + namespace :users do + delete 'deleted', to: 'deletions#delete' + end + end + end + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get 'up', to: 'rails/health#show', as: :rails_health_check diff --git a/lib/tasks/gdpr_delete.rake b/lib/tasks/gdpr_delete.rake index 5be874de7..3f8ef0252 100644 --- a/lib/tasks/gdpr_delete.rake +++ b/lib/tasks/gdpr_delete.rake @@ -46,7 +46,7 @@ namespace :gdpr do next end - if user.update(name: 'Deleted User', email: nil) + if user.soft_delete! users_deleted += 1 else errored_user_ids << user_id diff --git a/spec/request/api/internal/users/deletions_spec.rb b/spec/request/api/internal/users/deletions_spec.rb new file mode 100644 index 000000000..5f13d1e54 --- /dev/null +++ b/spec/request/api/internal/users/deletions_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'DELETE Users API', type: :request do + include ActiveJob::TestHelper + + let(:user_id) { '123456' } + let(:token) { 'test_token' } + let(:consumer) { create(:consumer, id: 1, name: 'openHPI') } + let!(:user) { create(:external_user, external_id: user_id, consumer_id: consumer.id, name: 'Test User', email: 'testmail@gmail.com') } + + let(:body) do + {user_id: user_id}.to_json + end + + let(:headers) do + { + 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json', + } + end + + around do |example| + original_token = ENV.fetch('OPENHPI_API_TOKEN', nil) + + ENV['OPENHPI_API_TOKEN'] = token + + example.run + + ENV['OPENHPI_API_TOKEN'] = original_token + end + + before do + # Ensure job queue is clean + clear_enqueued_jobs + clear_performed_jobs + # Stub request.raw_post to return the exact JSON body for signature verification + allow_any_instance_of(ActionDispatch::Request).to receive(:raw_post).and_return(body) + end + + describe 'DELETE /api/internal/users/deleted' do + context 'with valid token and signature' do + it 'anonymizes the user name to "Deleted User"' do + expect { delete '/api/internal/users/deleted', params: {user_id: user_id}, headers: headers } + .to change { user.reload.name }.to('Deleted User').and change { user.reload.email }.to(nil) + end + + it 'returns 200 OK' do + delete '/api/internal/users/deleted', + params: {user_id: user_id}, + headers: headers + + expect(response).to have_http_status(:ok) + end + end + + context 'with invalid token' do + it 'returns 401 Unauthorized' do + invalid_headers = headers.merge('Authorization' => 'Bearer invalid_token') + + delete '/api/internal/users/deleted', + params: {user_id: user_id}, + headers: invalid_headers + + expect(response).to have_http_status(:unauthorized) + end + + it 'does not anonymize the user' do + invalid_headers = headers.merge('Authorization' => 'Bearer invalid_token') + + expect { delete '/api/internal/users/deleted', params: {user_id: user_id}, headers: invalid_headers } + .not_to change { user.reload.name } + end + end + + context 'with missing Authorization header' do + it 'returns 401 Unauthorized' do + delete '/api/internal/users/deleted', + params: {user_id: user_id}, + headers: { + 'Content-Type' => 'application/json', + } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when soft_delete! fails' do + it 'returns 500 Internal Server Error' do + allow_any_instance_of(ExternalUser).to receive(:soft_delete!).and_raise(StandardError, 'Database error') + + delete '/api/internal/users/deleted', + params: {user_id: user_id}, + headers: headers + + expect(response).to have_http_status(:internal_server_error) + end + end + end +end