Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/controllers/api/api_controller.rb
Original file line number Diff line number Diff line change
@@ -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')
)
return head :unauthorized

Check warning on line 12 in app/controllers/api/api_controller.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Redundant `return` detected. Raw Output: app/controllers/api/api_controller.rb:12:9: C: Style/RedundantReturn: Redundant `return` detected.

Check warning on line 12 in app/controllers/api/api_controller.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Redundant `return` detected. Raw Output: app/controllers/api/api_controller.rb:12:9: C: Style/RedundantReturn: Redundant `return` detected.
end
end
end
end
25 changes: 25 additions & 0 deletions app/controllers/api/internal/users/deletions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +14 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The user deletion API endpoint always returns a success status, even if the underlying user.soft_delete! operation fails to update the database.
Severity: HIGH

Suggested Fix

Check the boolean return value of user.soft_delete!. If it returns false, respond with an appropriate error status, such as head :unprocessable_entity, instead of head :ok. This ensures that failures in the soft-deletion process are correctly reported to the API client.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: app/controllers/api/internal/users/deletions_controller.rb#L14-L16

Potential issue: The `DeletionsController#delete` action calls `user.soft_delete!`,
which returns `false` if the database update fails. The controller does not check this
return value and unconditionally returns an `HTTP 200 OK` status. This misleads the
calling service (e.g., openHPI) into believing a GDPR-mandated user deletion was
successful when it actually failed. This behavior contradicts the established
error-handling pattern in an existing Rake task (`gdpr_delete.rake`), which explicitly
checks for failures from the same method, indicating that failures are an expected
possibility that must be handled.

rescue StandardError => e
Rails.logger.error("Failed to delete user #{params[:user_id]}: #{e.message}")
head :internal_server_error
end
end
end
end
end
4 changes: 4 additions & 0 deletions app/models/external_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/tasks/gdpr_delete.rake
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions spec/request/api/internal/users/deletions_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading