diff --git a/.env b/.env new file mode 100644 index 000000000..c40ea49db --- /dev/null +++ b/.env @@ -0,0 +1 @@ +INTERNAL_API_TOKEN=supersecrettoken diff --git a/Gemfile b/Gemfile index bba6a591b..8b9aed37c 100644 --- a/Gemfile +++ b/Gemfile @@ -85,6 +85,10 @@ group :development, :staging do gem 'slim_lint', require: false end +group :development, :test do + gem 'dotenv-rails' +end + group :test do gem 'capybara' gem 'rails-controller-testing' diff --git a/Gemfile.lock b/Gemfile.lock index ccfc6922d..cce05cac8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,6 +150,10 @@ GEM docker-api (2.4.0) excon (>= 0.64.0) multi_json + dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) drb (2.2.3) erb (5.1.1) erubi (1.13.1) @@ -690,6 +694,7 @@ DEPENDENCIES charlock_holmes csv docker-api + dotenv-rails eventmachine factory_bot_rails faraday @@ -810,6 +815,8 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e docker-api (2.4.0) sha256=824be734f4cc8718189be9c8e795b6414acbbf7e8b082a06f959a27dd8dd63e6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (5.1.1) sha256=b2c26e7924551d9efbae998e17ddbef220937b6422b1d2ec7ae71417b5a1f4ec erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb new file mode 100644 index 000000000..3b5caafba --- /dev/null +++ b/app/controllers/api/api_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Api + class ApiController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + before_action :authenticate! + + def authenticate! + authenticate_or_request_with_http_token do |token, _options| + ActiveSupport::SecurityUtils.secure_compare( + token, + ENV.fetch('INTERNAL_API_TOKEN', nil) + ) + end + end + end +end diff --git a/app/controllers/api/internal/users_controller.rb b/app/controllers/api/internal/users_controller.rb new file mode 100644 index 000000000..ccfc52091 --- /dev/null +++ b/app/controllers/api/internal/users_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module Internal + class UsersController < Api::ApiController + def destroy + user = ExternalUser.where(external_id: params[:id]).first + + return head :not_found unless user + + user.soft_delete + head :ok + end + end + end +end diff --git a/app/models/external_user.rb b/app/models/external_user.rb index 323ae9ab5..15e6b8e0d 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, deleted_at: Time.zone.now) + end + def webauthn_name "#{consumer.name}: #{displayname}" end diff --git a/config/routes.rb b/config/routes.rb index 9a5e8b154..0c194a111 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,6 +193,12 @@ mount ActionCable.server => '/cable' mount RailsAdmin::Engine => '/rails_admin', as: 'rails_admin' + namespace :api do + namespace :internal do + resources :users, only: :destroy + 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/db/cable_schema.rb b/db/cable_schema.rb index ad90fec73..2efdbf273 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -12,7 +12,7 @@ ActiveRecord::Schema[8.0].define(version: 2024_09_30_231316) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", null: false diff --git a/db/migrate/20260421114010_add_deleted_at_to_external_users.rb b/db/migrate/20260421114010_add_deleted_at_to_external_users.rb new file mode 100644 index 000000000..5b3b3a04a --- /dev/null +++ b/db/migrate/20260421114010_add_deleted_at_to_external_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDeletedAtToExternalUsers < ActiveRecord::Migration[8.0] + def change + add_column :external_users, :deleted_at, :datetime, null: true, default: nil + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index d6324fc1d..f42738170 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -12,7 +12,7 @@ ActiveRecord::Schema[8.0].define(version: 2024_09_04_193154) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" create_table "solid_queue_blocked_executions", force: :cascade do |t| t.bigint "job_id", null: false diff --git a/db/schema.rb b/db/schema.rb index f95ae3f2d..9139de5e8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,11 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_13_114125) do +ActiveRecord::Schema[8.0].define(version: 2026_04_21_114010) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" enable_extension "pgcrypto" - enable_extension "plpgsql" create_table "anomaly_notifications", id: :serial, force: :cascade do |t| t.integer "contributor_id", null: false @@ -273,6 +273,7 @@ t.datetime "updated_at" t.boolean "platform_admin", default: false, null: false t.string "webauthn_user_id" + t.datetime "deleted_at" t.index ["external_id", "consumer_id"], name: "index_external_users_on_external_id_and_consumer_id", unique: true end diff --git a/docs/environment_variables.md b/docs/environment_variables.md index 551f41651..344efcb8f 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -25,3 +25,4 @@ The following environment variables are specifically support in CodeOcean and ar | `LISTEN_ADDRESS` | `127.0.0.1` in `development` | Specifies the IP address the Vagrant VM server should attach to during development | | `HEADLESS` | `false` | Enables the test environment to work without a window manager for feature tests (e.g., using Vagrant) | | `BROWSER` | `chrome` | Specifies the browser to be used for system tests. Supported are `chrome` or `firefox` | +| `INTERNAL_API_TOKEN` | ` ` | Bearer token for authenticating requests from openHPI for user anonymization | diff --git a/lib/tasks/gdpr_delete.rake b/lib/tasks/gdpr_delete.rake index 5be874de7..98307cbd9 100644 --- a/lib/tasks/gdpr_delete.rake +++ b/lib/tasks/gdpr_delete.rake @@ -46,9 +46,11 @@ namespace :gdpr do next end - if user.update(name: 'Deleted User', email: nil) + begin + user.soft_delete users_deleted += 1 - else + rescue StandardError => e + warn "An error occurred while anonymizing user #{user_id}: #{e.message}" errored_user_ids << user_id end end diff --git a/spec/controllers/api/api_controller_spec.rb b/spec/controllers/api/api_controller_spec.rb new file mode 100644 index 000000000..0b77cb30d --- /dev/null +++ b/spec/controllers/api/api_controller_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::ApiController do + controller(described_class) do + def index + head :ok + end + end + + before do + routes.draw do + get 'index' => 'api/api#index' + end + end + + describe 'authentication' do + context 'with valid token' do + it 'allows the request' do + request.headers['Authorization'] = 'Bearer supersecrettoken' + + get :index + + expect(response).to have_http_status(:ok) + end + end + + context 'with invalid token' do + it 'returns 401 Unauthorized' do + request.headers['Authorization'] = 'Bearer invalid_token' + + get :index + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'without token' do + it 'returns 401 Unauthorized' do + get :index + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'without the INTERNAL_API_TOKEN being set' do + it 'returns 401 Unauthorized' do + allow(ENV) + .to receive(:fetch) + .with('INTERNAL_API_TOKEN', nil) + .and_return(nil) + + request.headers['Authorization'] = "Bearer #{ENV.fetch('INTERNAL_API_TOKEN', nil)}" + get :index + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/models/external_user_spec.rb b/spec/models/external_user_spec.rb index c43a9223c..4dd8cea6e 100644 --- a/spec/models/external_user_spec.rb +++ b/spec/models/external_user_spec.rb @@ -42,4 +42,19 @@ expect(build(:external_user).learner?).to be true end end + + describe '#soft_delete' do + let(:user) { create(:external_user, name: 'Test User', email: 'testmail@gmail.com') } + + it 'sets the name to "Deleted User" and email to nil' do + user.soft_delete + expect(user.name).to eq('Deleted User') + expect(user.email).to be_nil + end + + it 'raises an error if the update fails' do + allow(user).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(user)) + expect { user.soft_delete }.to raise_error(ActiveRecord::RecordInvalid) + end + end end diff --git a/spec/request/api/internal/users/destroy_spec.rb b/spec/request/api/internal/users/destroy_spec.rb new file mode 100644 index 000000000..91b3e3e5f --- /dev/null +++ b/spec/request/api/internal/users/destroy_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +include ActiveSupport::Testing::TimeHelpers + +require 'rails_helper' + +RSpec.describe 'DELETE Users API', type: :request do + let(:headers) { {'Authorization' => 'Bearer supersecrettoken'} } + + describe 'DELETE /api/internal/users' do + it 'soft deletes a user' do + user = create(:external_user, external_id: '123456') + + freeze_time + + expect { delete '/api/internal/users/123456', headers: headers } + .to change { user.reload.deleted_at }.to(Time.zone.now) + end + + it 'returns an error if the user is not found' do + delete '/api/internal/users/123456', + headers: headers + expect(response).to have_http_status(:not_found) + end + end +end