summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-04 05:40:54 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-04 05:40:54 +0200
commit974062f16169f6d07f2289564be6d72610b8770e (patch)
treeb18f297a9d4326a3ce36f5192fbc4992fcb28e7d
parentbe2cdea85177ac016e56dcce6cbe6015d754adf5 (diff)
Witness every promotion and demotion made through the roles form
-rw-r--r--app/controllers/users_controller.rb20
-rw-r--r--app/helpers/node_actions_helper.rb12
-rw-r--r--app/models/node_action.rb6
-rw-r--r--app/models/user.rb51
-rw-r--r--config/locales/de.yml8
-rw-r--r--config/locales/en.yml9
-rw-r--r--test/controllers/users_controller_test.rb46
-rw-r--r--test/models/user_test.rb66
8 files changed, 214 insertions, 4 deletions
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 1bd436e5..b06d11fd 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -50,12 +50,26 @@ class UsersController < ApplicationController
50 end 50 end
51 51
52 permitted = user_params 52 permitted = user_params
53 desired = permitted.key?(:roles) ? permitted.delete(:roles) : nil
54 refusals = []
55 saved = false
53 56
54 if @user.update(permitted) 57 User.transaction do
58 saved = @user.update(permitted)
59 raise ActiveRecord::Rollback unless saved
60
61 refusals = desired ? @user.update_roles!(desired, :actor => current_user) : []
62 raise ActiveRecord::Rollback if refusals.any?
63 end
64
65 if !saved
66 render :edit
67 elsif refusals.any?
68 flash.now[:error] = refusals.map { |r| t("flash.users.#{r}", :login => @user.login) }.to_sentence
69 render :edit
70 else
55 flash[:notice] = t("flash.users.updated", :login => @user.login) 71 flash[:notice] = t("flash.users.updated", :login => @user.login)
56 redirect_to user_path(@user) 72 redirect_to user_path(@user)
57 else
58 render :edit
59 end 73 end
60 end 74 end
61 75
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index 7dd55bdb..4cf990b8 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -24,6 +24,8 @@ module NodeActionsHelper
24 "user_reactivate" => "user-check", 24 "user_reactivate" => "user-check",
25 "redaktion_grant" => "users-plus", 25 "redaktion_grant" => "users-plus",
26 "redaktion_revoke" => "users-minus", 26 "redaktion_revoke" => "users-minus",
27 "admin_grant" => "shield-plus",
28 "admin_revoke" => "shield-minus",
27 "event_create" => "calendar-plus", 29 "event_create" => "calendar-plus",
28 "event_update" => "calendar-event", 30 "event_update" => "calendar-event",
29 "event_destroy" => "calendar-x" 31 "event_destroy" => "calendar-x"
@@ -371,6 +373,16 @@ module NodeActionsHelper
371 :target => user_participant_ref(action)).html_safe 373 :target => user_participant_ref(action)).html_safe
372 end 374 end
373 375
376 def summarize_admin_grant action
377 t("node_actions.admin_grant", :actor => actor_ref(action),
378 :target => user_participant_ref(action)).html_safe
379 end
380
381 def summarize_admin_revoke action
382 t("node_actions.admin_revoke", :actor => actor_ref(action),
383 :target => user_participant_ref(action)).html_safe
384 end
385
374 def summarize_user_create action 386 def summarize_user_create action
375 t("node_actions.user_create", :actor => actor_ref(action), 387 t("node_actions.user_create", :actor => actor_ref(action),
376 :target => user_participant_ref(action)).html_safe 388 :target => user_participant_ref(action)).html_safe
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 0167762b..bfa469b1 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -105,6 +105,12 @@ class NodeAction < ApplicationRecord
105 # otp_disable is self-service; otp_reset and all three account 105 # otp_disable is self-service; otp_reset and all three account
106 # verbs are an administrator acting on someone else, so actor and 106 # verbs are an administrator acting on someone else, so actor and
107 # participant differ: 107 # participant differ:
108 # "redaktion_grant" / "redaktion_revoke" / "admin_grant" /
109 # "admin_revoke" -- role changes. Both pairs come from
110 # User#grant_* / #revoke_*, so the roles form reaches them through
111 # update_roles! rather than writing the attribute: witnessing is the
112 # reason the form does not touch roles directly. Alumni changes record
113 # as user_deactivate / user_reactivate, not as a role verb.
108 # "target_login" -- flat string, the affected account's login 114 # "target_login" -- flat string, the affected account's login
109 # 115 #
110 # "event_create" / "event_update" / "event_destroy" (calendar 116 # "event_create" / "event_update" / "event_destroy" (calendar
diff --git a/app/models/user.rb b/app/models/user.rb
index adfdc564..786f8d14 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -132,6 +132,7 @@ class User < ApplicationRecord
132 132
133 def deactivate!(actor:) 133 def deactivate!(actor:)
134 return false if alumni? 134 return false if alumni?
135 return false if actor == self
135 transaction do 136 transaction do
136 update_column(:roles, (roles | ["alumni"]).sort) 137 update_column(:roles, (roles | ["alumni"]).sort)
137 NodeAction.record!(:participants => [self], :user => actor, 138 NodeAction.record!(:participants => [self], :user => actor,
@@ -174,6 +175,56 @@ class User < ApplicationRecord
174 :revoked 175 :revoked
175 end 176 end
176 177
178 def grant_admin!(actor:)
179 return :already if is_admin?
180 return :no_second_factor unless otp_enrolled?
181
182 transaction do
183 update_column(:roles, (roles | ["admin"]).sort)
184 NodeAction.record!(:participants => [self], :user => actor,
185 :action => "admin_grant", :target_login => login)
186 end
187 :granted
188 end
189
190 def revoke_admin!(actor:)
191 return :already unless is_admin?
192 return :self unless actor != self
193
194 transaction do
195 update_column(:roles, (roles - ["admin"]).sort)
196 NodeAction.record!(:participants => [self], :user => actor,
197 :action => "admin_revoke", :target_login => login)
198 end
199 :revoked
200 end
201
202 def update_roles!(desired, actor:)
203 desired = Array(desired).map(&:to_s) & ROLES
204 refusals = []
205
206 transaction do
207 refusals << :admin_not_self if is_admin? && !desired.include?("admin") && actor == self
208 refusals << :redaktion_not_self if redaktion? && !desired.include?("redaktion") && actor == self
209 refusals << :cannot_deactivate_self if !alumni? && desired.include?("alumni") && actor == self
210 refusals << :admin_needs_otp if !is_admin? && desired.include?("admin") && !otp_enrolled?
211 refusals << :redaktion_needs_otp if !redaktion? && desired.include?("redaktion") && !otp_enrolled?
212
213 raise ActiveRecord::Rollback if refusals.any?
214
215 revoke_admin!(:actor => actor) if is_admin? && !desired.include?("admin")
216 revoke_redaktion!(:actor => actor) if redaktion? && !desired.include?("redaktion")
217 reactivate!(:actor => actor) if alumni? && !desired.include?("alumni")
218
219 grant_admin!(:actor => actor) if !is_admin? && desired.include?("admin")
220 grant_redaktion!(:actor => actor) if !redaktion? && desired.include?("redaktion")
221
222 deactivate!(:actor => actor) if !alumni? && desired.include?("alumni")
223 end
224
225 refusals
226 end
227
177 # otp_secret present == enrolled. otp_pending_secret holds the secret 228 # otp_secret present == enrolled. otp_pending_secret holds the secret
178 # between QR display and first-code confirmation. otp_consumed_timestep 229 # between QR display and first-code confirmation. otp_consumed_timestep
179 # makes every accepted code single-use (replay guard within the drift 230 # makes every accepted code single-use (replay guard within the drift
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 2139e0da..a1b29199 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -237,6 +237,8 @@ de:
237 user_reactivate: "%{actor} hat %{target} reaktiviert" 237 user_reactivate: "%{actor} hat %{target} reaktiviert"
238 redaktion_grant: "%{actor} hat %{target} in die Redaktion aufgenommen" 238 redaktion_grant: "%{actor} hat %{target} in die Redaktion aufgenommen"
239 redaktion_revoke: "%{actor} hat %{target} aus der Redaktion entfernt" 239 redaktion_revoke: "%{actor} hat %{target} aus der Redaktion entfernt"
240 admin_grant: "%{actor} hat %{target} zum Administrator gemacht"
241 admin_revoke: "%{actor} hat %{target} die Administratorenrechte entzogen"
240 event_create: "%{actor} hat den Termin %{event} angelegt" 242 event_create: "%{actor} hat den Termin %{event} angelegt"
241 event_create_on: "%{actor} hat den Termin %{event} unter %{subject} angelegt" 243 event_create_on: "%{actor} hat den Termin %{event} unter %{subject} angelegt"
242 event_update: "%{actor} hat den Termin %{event} geändert" 244 event_update: "%{actor} hat den Termin %{event} geändert"
@@ -553,6 +555,10 @@ de:
553 node_list: 555 node_list:
554 search_placeholder: "Titel, Abstract, Text durchsuchen…" 556 search_placeholder: "Titel, Abstract, Text durchsuchen…"
555 no_revision: "keine" 557 no_revision: "keine"
558 flag_locked: "Gesperrt von %{login}"
559 flag_embargo: "Wird am %{date} veröffentlicht"
560 flag_draft: "Unveröffentlichter Entwurf"
561 flag_no_head: "Noch nie veröffentlicht"
556 trashed: 562 trashed:
557 title: "Papierkorb" 563 title: "Papierkorb"
558 empty: "Der Papierkorb ist leer." 564 empty: "Der Papierkorb ist leer."
@@ -640,6 +646,8 @@ de:
640 redaktion_revoked: "%{login} gehört nicht mehr zur Redaktion." 646 redaktion_revoked: "%{login} gehört nicht mehr zur Redaktion."
641 redaktion_needs_otp: "%{login} braucht zuerst einen zweiten Faktor." 647 redaktion_needs_otp: "%{login} braucht zuerst einen zweiten Faktor."
642 redaktion_not_self: "Die eigene Redaktions-Rolle kann nicht abgegeben werden." 648 redaktion_not_self: "Die eigene Redaktions-Rolle kann nicht abgegeben werden."
649 admin_not_self: "Du kannst dir die Administratorenrechte nicht selbst entziehen."
650 admin_needs_otp: "%{login} muss zuerst einen zweiten Faktor einrichten, um Administrator zu werden."
643 assets: 651 assets:
644 created: "Asset wurde angelegt." 652 created: "Asset wurde angelegt."
645 updated: "Asset wurde aktualisiert." 653 updated: "Asset wurde aktualisiert."
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b4d6e4ae..fc011437 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -184,6 +184,8 @@ en:
184 user_reactivate: "%{actor} reactivated %{target}" 184 user_reactivate: "%{actor} reactivated %{target}"
185 redaktion_grant: "%{actor} added %{target} to Redaktion" 185 redaktion_grant: "%{actor} added %{target} to Redaktion"
186 redaktion_revoke: "%{actor} removed %{target} from Redaktion" 186 redaktion_revoke: "%{actor} removed %{target} from Redaktion"
187 admin_grant: "%{actor} made %{target} an administrator"
188 admin_revoke: "%{actor} removed administrator rights from %{target}"
187 event_create: "%{actor} added the event %{event}" 189 event_create: "%{actor} added the event %{event}"
188 event_create_on: "%{actor} added the event %{event} to %{subject}" 190 event_create_on: "%{actor} added the event %{event} to %{subject}"
189 event_update: "%{actor} changed the event %{event}" 191 event_update: "%{actor} changed the event %{event}"
@@ -500,6 +502,10 @@ en:
500 node_list: 502 node_list:
501 search_placeholder: "Search title, abstract, body…" 503 search_placeholder: "Search title, abstract, body…"
502 no_revision: "none" 504 no_revision: "none"
505 flag_locked: "Locked by %{login}"
506 flag_embargo: "Publishes %{date}"
507 flag_draft: "Unpublished draft"
508 flag_no_head: "Never published"
503 trashed: 509 trashed:
504 title: "Trash" 510 title: "Trash"
505 empty: "The Trash is empty." 511 empty: "The Trash is empty."
@@ -602,7 +608,8 @@ en:
602 redaktion_revoked: "%{login} is no longer part of Redaktion." 608 redaktion_revoked: "%{login} is no longer part of Redaktion."
603 redaktion_needs_otp: "%{login} needs a second factor first." 609 redaktion_needs_otp: "%{login} needs a second factor first."
604 redaktion_not_self: "You cannot give up your own Redaktion role." 610 redaktion_not_self: "You cannot give up your own Redaktion role."
605 611 admin_not_self: "You cannot remove your own administrator rights."
612 admin_needs_otp: "%{login} must enrol a second factor before becoming an administrator."
606 assets: 613 assets:
607 created: "Asset was successfully created." 614 created: "Asset was successfully created."
608 updated: "Asset was successfully updated." 615 updated: "Asset was successfully updated."
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index 69c535f5..aeff9bc7 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -357,4 +357,50 @@ class UsersControllerTest < ActionController::TestCase
357 assert_redirected_to new_elevation_path 357 assert_redirected_to new_elevation_path
358 assert_equal ["admin", "redaktion"], users(:aaron).reload.roles.sort 358 assert_equal ["admin", "redaktion"], users(:aaron).reload.roles.sort
359 end 359 end
360
361 test "promoting through the roles form leaves a log entry" do
362 login_as :aaron
363 elevate_session!
364 target = users(:redella)
365 target.update_column(:otp_secret, ROTP::Base32.random)
366
367 assert_difference -> { NodeAction.where(:action => "admin_grant").count }, 1 do
368 put :update, params: { :locale => "de", :id => target.id,
369 :user => { :roles => ["", "redaktion", "admin"] } }
370 end
371
372 assert_redirected_to user_path(target)
373 assert_equal %w[admin redaktion], target.reload.roles.sort
374 end
375
376 test "a refused promotion re-renders with an error and changes nothing" do
377 login_as :aaron
378 elevate_session!
379 target = users(:quentin)
380
381 assert_no_difference -> { NodeAction.count } do
382 put :update, params: { :locale => "de", :id => target.id,
383 :user => { :roles => ["", "admin"] } }
384 end
385
386 assert_response :success
387 assert_empty target.reload.roles
388 assert_not_nil flash[:error]
389 assert_nil flash[:notice]
390 end
391
392 test "a validation failure leaves no promotion behind" do
393 login_as :aaron
394 elevate_session!
395 target = users(:redella)
396 target.update_column(:otp_secret, ROTP::Base32.random)
397
398 assert_no_difference -> { NodeAction.count } do
399 put :update, params: { :locale => "de", :id => target.id,
400 :user => { :email => "", :roles => ["", "redaktion", "admin"] } }
401 end
402
403 assert_response :success
404 assert_equal %w[redaktion], target.reload.roles
405 end
360end 406end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 62552ee7..5ccc53a9 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -6,6 +6,11 @@ class UserTest < ActiveSupport::TestCase
6 include AuthenticatedTestHelper 6 include AuthenticatedTestHelper
7 fixtures :users 7 fixtures :users
8 8
9 def role_entries
10 NodeAction.where(:action => %w[admin_grant admin_revoke redaktion_grant
11 redaktion_revoke user_deactivate user_reactivate])
12 end
13
9 def test_should_create_user 14 def test_should_create_user
10 assert_difference 'User.count' do 15 assert_difference 'User.count' do
11 user = create_user 16 user = create_user
@@ -187,6 +192,67 @@ class UserTest < ActiveSupport::TestCase
187 redella.update_column(:last_login_at, nil) 192 redella.update_column(:last_login_at, nil)
188 assert_equal :never, redella.staleness_tier(now) 193 assert_equal :never, redella.staleness_tier(now)
189 end 194 end
195
196 test "granting a role through update_roles! is witnessed" do
197 target = users(:redella)
198 target.update_column(:otp_secret, ROTP::Base32.random)
199
200 assert_difference -> { role_entries.count }, 1 do
201 assert_empty target.update_roles!(%w[redaktion admin], :actor => users(:aaron))
202 end
203
204 assert_equal %w[admin redaktion], target.reload.roles.sort
205 entry = role_entries.order(:id).last
206 assert_equal "admin_grant", entry.action
207 assert_equal users(:aaron).id, entry.user_id
208 assert_equal "redella", entry.metadata["target_login"]
209 end
210
211 test "a refused grant applies nothing at all" do
212 target = users(:quentin)
213 assert_not target.otp_enrolled?
214
215 assert_no_difference -> { role_entries.count } do
216 refusals = target.update_roles!(%w[redaktion admin], :actor => users(:aaron))
217 assert_includes refusals, :admin_needs_otp
218 assert_includes refusals, :redaktion_needs_otp
219 end
220
221 assert_empty target.reload.roles, "a refusal must leave the stored set untouched"
222 end
223
224 test "nobody demotes themselves through update_roles!" do
225 actor = users(:aaron)
226
227 refusals = actor.update_roles!([], :actor => actor)
228
229 assert_includes refusals, :admin_not_self
230 assert_includes refusals, :redaktion_not_self
231 assert_equal %w[admin redaktion], actor.reload.roles.sort
232 end
233
234 test "marking an account alumni through update_roles! is witnessed as a deactivation" do
235 target = users(:redella)
236
237 assert_difference -> { role_entries.where(:action => "user_deactivate").count }, 1 do
238 assert_empty target.update_roles!(%w[redaktion alumni], :actor => users(:aaron))
239 end
240
241 assert target.reload.alumni?
242 assert target.redaktion?, "deactivate! preserves the other roles"
243 end
244
245 test "revoking and granting in one submission apply in the right order" do
246 target = users(:redella)
247 target.update_column(:otp_secret, ROTP::Base32.random)
248
249 assert_empty target.update_roles!(%w[admin], :actor => users(:aaron))
250
251 assert_equal %w[admin], target.reload.roles
252 actions = role_entries.order(:id).last(2).map(&:action)
253 assert_includes actions, "redaktion_revoke"
254 assert_includes actions, "admin_grant"
255 end
190 256
191protected 257protected
192 def create_user(options = {}) 258 def create_user(options = {})