summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-01 04:14:00 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-01 04:14:00 +0200
commitabd7ee1fc2ecc15b50944db30c59bedc26ec41b6 (patch)
tree821bb8ca610664c57d9065bf62285166243500a4
parent6df48c1413a14516e7ee8919f33fbc13f0141966 (diff)
Let Redaktion grant and revoke its own role
Any holder may add or remove another account, witnessed as redaktion_grant/revoke so the vouching is legible. Not behind elevation: onboarding must not wait for a keyholder, and a compromised Redaktion account can already publish.
-rw-r--r--app/controllers/users_controller.rb30
-rw-r--r--app/helpers/node_actions_helper.rb14
-rw-r--r--app/models/user.rb31
-rw-r--r--app/views/users/_user.html.erb17
-rw-r--r--app/views/users/index.html.erb1
-rw-r--r--config/locales/de.yml13
-rw-r--r--config/locales/en.yml15
-rw-r--r--config/routes.rb2
-rw-r--r--test/controllers/users_controller_test.rb20
9 files changed, 134 insertions, 9 deletions
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 583ebac0..9b9d64e5 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -5,17 +5,17 @@ class UsersController < ApplicationController
5 # Private 5 # Private
6 6
7 before_action :login_required 7 before_action :login_required
8 before_action :find_user, :only => [:show, :edit, :update, :reset_otp, :deactivate, :reactivate] 8 before_action :find_user, :only => [:show, :edit, :update, :reset_otp, :deactivate, :reactivate, :grant_redaktion, :revoke_redaktion]
9 before_action :require_admin, :only => [:index, :new, :create, :reset_otp, :deactivate, :reactivate] 9 before_action :require_redaktion, :only => [:index]
10 before_action :require_admin, :only => [:new, :create, :reset_otp, :deactivate, :reactivate]
10 before_action :require_elevation, :only => [:new, :create, :reset_otp, :deactivate, :reactivate] 11 before_action :require_elevation, :only => [:new, :create, :reset_otp, :deactivate, :reactivate]
11 before_action :verify_status, :except => [:index] 12 before_action :verify_status, :except => [:index, :grant_redaktion, :revoke_redaktion]
12 13
13 layout 'admin' 14 layout 'admin'
14 15
15 ROLE_PRESETS = { 16 ROLE_PRESETS = {
16 "editor" => [], 17 "editor" => [],
17 "redaktion" => ["redaktion"], 18 "redaktion" => ["redaktion"],
18 "admin" => ["admin", "redaktion"]
19 }.freeze 19 }.freeze
20 20
21 GROUP_ORDER = [:admin, :redaktion, :editor, :alumni].freeze 21 GROUP_ORDER = [:admin, :redaktion, :editor, :alumni].freeze
@@ -74,6 +74,28 @@ class UsersController < ApplicationController
74 redirect_to users_path 74 redirect_to users_path
75 end 75 end
76 76
77 def grant_redaktion
78 return deny_role_access(:redaktion_required) unless current_user.redaktion?
79
80 case @user.grant_redaktion!(:actor => current_user)
81 when :granted then flash[:notice] = t("flash.users.redaktion_granted", :login => @user.login)
82 when :no_second_factor then flash[:error] = t("flash.users.redaktion_needs_otp", :login => @user.login)
83 end
84
85 redirect_to users_path
86 end
87
88 def revoke_redaktion
89 return deny_role_access(:redaktion_required) unless current_user.redaktion?
90
91 case @user.revoke_redaktion!(:actor => current_user)
92 when :revoked then flash[:notice] = t("flash.users.redaktion_revoked", :login => @user.login)
93 when :self then flash[:error] = t("flash.users.redaktion_not_self")
94 end
95
96 redirect_to users_path
97 end
98
77 def reset_otp 99 def reset_otp
78 @user.disable_otp!(:actor => current_user) 100 @user.disable_otp!(:actor => current_user)
79 flash[:notice] = t("flash.users.otp_reset", :login => @user.login) 101 flash[:notice] = t("flash.users.otp_reset", :login => @user.login)
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index f57ef84f..53f6ecd0 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -20,7 +20,9 @@ module NodeActionsHelper
20 "otp_disable" => "shield-off", 20 "otp_disable" => "shield-off",
21 "otp_reset" => "shield-x", 21 "otp_reset" => "shield-x",
22 "user_deactivate" => "user-off", 22 "user_deactivate" => "user-off",
23 "user_reactivate" => "user-check" 23 "user_reactivate" => "user-check",
24 "redaktion_grant" => "user-plus",
25 "redaktion_revoke" => "user-minus"
24 }.freeze 26 }.freeze
25 27
26 def verb_icon action 28 def verb_icon action
@@ -283,4 +285,14 @@ module NodeActionsHelper
283 t("node_actions.user_reactivate", :actor => actor_ref(action), 285 t("node_actions.user_reactivate", :actor => actor_ref(action),
284 :target => user_participant_ref(action)).html_safe 286 :target => user_participant_ref(action)).html_safe
285 end 287 end
288
289 def summarize_redaktion_grant action
290 t("node_actions.redaktion_grant", :actor => actor_ref(action),
291 :target => user_participant_ref(action)).html_safe
292 end
293
294 def summarize_redaktion_revoke action
295 t("node_actions.redaktion_revoke", :actor => actor_ref(action),
296 :target => user_participant_ref(action)).html_safe
297 end
286end 298end
diff --git a/app/models/user.rb b/app/models/user.rb
index bf0f40ee..c3035a02 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -25,6 +25,7 @@ class User < ApplicationRecord
25 :message => Authentication.bad_email_message 25 :message => Authentication.bad_email_message
26 26
27 validate :roles_are_known 27 validate :roles_are_known
28 validate :admin_needs_second_factor
28 29
29 # Authenticates a user by their login name and unencrypted password. Returns the user or nil. 30 # Authenticates a user by their login name and unencrypted password. Returns the user or nil.
30 def self.authenticate(login, password) 31 def self.authenticate(login, password)
@@ -136,6 +137,30 @@ class User < ApplicationRecord
136 true 137 true
137 end 138 end
138 139
140 def grant_redaktion!(actor:)
141 return :already if redaktion?
142 return :no_second_factor unless otp_enrolled?
143
144 transaction do
145 update_column(:roles, (roles | ["redaktion"]).sort)
146 NodeAction.record!(:participants => [self], :user => actor,
147 :action => "redaktion_grant", :target_login => login)
148 end
149 :granted
150 end
151
152 def revoke_redaktion!(actor:)
153 return :already unless redaktion?
154 return :self unless actor != self
155
156 transaction do
157 update_column(:roles, (roles - ["redaktion"]).sort)
158 NodeAction.record!(:participants => [self], :user => actor,
159 :action => "redaktion_revoke", :target_login => login)
160 end
161 :revoked
162 end
163
139 # otp_secret present == enrolled. otp_pending_secret holds the secret 164 # otp_secret present == enrolled. otp_pending_secret holds the secret
140 # between QR display and first-code confirmation. otp_consumed_timestep 165 # between QR display and first-code confirmation. otp_consumed_timestep
141 # makes every accepted code single-use (replay guard within the drift 166 # makes every accepted code single-use (replay guard within the drift
@@ -213,4 +238,10 @@ class User < ApplicationRecord
213 unknown = roles.to_a - ROLES 238 unknown = roles.to_a - ROLES
214 errors.add(:roles, :unknown, :list => unknown.join(", ")) if unknown.any? 239 errors.add(:roles, :unknown, :list => unknown.join(", ")) if unknown.any?
215 end 240 end
241
242 def admin_needs_second_factor
243 return unless roles.include?("admin")
244 return if otp_secret.present?
245 errors.add(:roles, :admin_needs_otp)
246 end
216end 247end
diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb
index ff9d4e37..ba82375d 100644
--- a/app/views/users/_user.html.erb
+++ b/app/views/users/_user.html.erb
@@ -26,5 +26,22 @@
26 <% end %> 26 <% end %>
27 <% end %> 27 <% end %>
28 </td> 28 </td>
29 <td>
30 <% if current_user.redaktion? && !user.alumni? %>
31 <% if user.redaktion? %>
32 <% unless user == current_user %>
33 <%= button_to t(".revoke_redaktion"), revoke_redaktion_user_path(user), method: :put,
34 form: { data: { confirm: t(".confirm_revoke_redaktion", :login => user.login) },
35 class: 'button_to destructive' } %>
36 <% end %>
37 <% elsif user.otp_enrolled? %>
38 <%= button_to t(".grant_redaktion"), grant_redaktion_user_path(user), method: :put,
39 form: { data: { confirm: t(".confirm_grant_redaktion", :login => user.login) },
40 class: 'button_to state_changing' } %>
41 <% else %>
42 <span class="field_hint"><%= t(".needs_otp") %></span>
43 <% end %>
44 <% end %>
45 </td>
29</tr> 46</tr>
30<% end %> 47<% end %>
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
index 854811a2..2936bbea 100644
--- a/app/views/users/index.html.erb
+++ b/app/views/users/index.html.erb
@@ -8,6 +8,7 @@
8 <% end %> 8 <% end %>
9 <% end %> 9 <% end %>
10</p> 10</p>
11<p class="field_hint"><%= t(".admin_hint") %></p>
11 12
12<% UsersController::GROUP_ORDER.each do |group| %> 13<% UsersController::GROUP_ORDER.each do |group| %>
13 <% members = @users[group] || [] %> 14 <% members = @users[group] || [] %>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 6b5c97cc..67117945 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -150,6 +150,7 @@ de:
150 attributes: 150 attributes:
151 roles: 151 roles:
152 unknown: "enthält unbekannte Rollen: %{list}" 152 unknown: "enthält unbekannte Rollen: %{list}"
153 admin_needs_otp: "Die Administrator-Rolle setzt einen zweiten Faktor voraus. Der Nutzer muss diesen zuerst selbst unter »Mein Konto« einrichten"
153 related_asset: 154 related_asset:
154 attributes: 155 attributes:
155 headline: 156 headline:
@@ -226,6 +227,8 @@ de:
226 otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt" 227 otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt"
227 user_deactivate: "%{actor} hat %{target} deaktiviert" 228 user_deactivate: "%{actor} hat %{target} deaktiviert"
228 user_reactivate: "%{actor} hat %{target} reaktiviert" 229 user_reactivate: "%{actor} hat %{target} reaktiviert"
230 redaktion_grant: "%{actor} hat %{target} in die Redaktion aufgenommen"
231 redaktion_revoke: "%{actor} hat %{target} aus der Redaktion entfernt"
229 232
230 open_gallery: "Gallerie anzeigen" 233 open_gallery: "Gallerie anzeigen"
231 asset_licenses: 234 asset_licenses:
@@ -291,6 +294,11 @@ de:
291 deactivate: "Deaktivieren" 294 deactivate: "Deaktivieren"
292 reactivate: "Reaktivieren" 295 reactivate: "Reaktivieren"
293 confirm_deactivate: "%{login} deaktivieren? Die Anmeldung wird sofort verweigert, Zuschreibungen bleiben erhalten." 296 confirm_deactivate: "%{login} deaktivieren? Die Anmeldung wird sofort verweigert, Zuschreibungen bleiben erhalten."
297 grant_redaktion: "In die Redaktion aufnehmen"
298 revoke_redaktion: "Aus der Redaktion entfernen"
299 confirm_grant_redaktion: "%{login} in die Redaktion aufnehmen? Damit darf %{login} in den geschützten Bereichen veröffentlichen."
300 confirm_revoke_redaktion: "%{login} aus der Redaktion entfernen?"
301 needs_otp: "zweiter Faktor fehlt"
294 index: 302 index:
295 title: "Benutzerkonten" 303 title: "Benutzerkonten"
296 create_editor: "Editor-Konto anlegen" 304 create_editor: "Editor-Konto anlegen"
@@ -301,6 +309,7 @@ de:
301 group_editor: "Editors" 309 group_editor: "Editors"
302 group_alumni: "Ehemalige" 310 group_alumni: "Ehemalige"
303 group_empty: "— keine —" 311 group_empty: "— keine —"
312 admin_hint: "Administrative Rechte lassen sich erst vergeben, nachdem der Nutzer sich angemeldet und einen zweiten Faktor eingerichtet hat."
304 labels: 313 labels:
305 roles: "Rollen" 314 roles: "Rollen"
306 roles: 315 roles:
@@ -595,6 +604,10 @@ de:
595 deactivated: "%{login} ist jetzt alumni und kann sich nicht mehr anmelden." 604 deactivated: "%{login} ist jetzt alumni und kann sich nicht mehr anmelden."
596 reactivated: "%{login} kann sich wieder anmelden." 605 reactivated: "%{login} kann sich wieder anmelden."
597 cannot_deactivate_self: "Das eigene Konto kann nicht deaktiviert werden." 606 cannot_deactivate_self: "Das eigene Konto kann nicht deaktiviert werden."
607 redaktion_granted: "%{login} gehört jetzt zur Redaktion."
608 redaktion_revoked: "%{login} gehört nicht mehr zur Redaktion."
609 redaktion_needs_otp: "%{login} braucht zuerst einen zweiten Faktor."
610 redaktion_not_self: "Die eigene Redaktions-Rolle kann nicht abgegeben werden."
598 assets: 611 assets:
599 created: "Asset wurde angelegt." 612 created: "Asset wurde angelegt."
600 updated: "Asset wurde aktualisiert." 613 updated: "Asset wurde aktualisiert."
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e4c7ecc4..95eb7e94 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -97,6 +97,7 @@ en:
97 attributes: 97 attributes:
98 roles: 98 roles:
99 unknown: "contains unknown roles: %{list}" 99 unknown: "contains unknown roles: %{list}"
100 admin_needs_otp: "Administrator role requires an enrolled second factor. The user has to set one up under My account first"
100 related_asset: 101 related_asset:
101 attributes: 102 attributes:
102 headline: 103 headline:
@@ -170,6 +171,8 @@ en:
170 otp_reset: "%{actor} reset the second factor of %{target}" 171 otp_reset: "%{actor} reset the second factor of %{target}"
171 user_deactivate: "%{actor} deactivated %{target}" 172 user_deactivate: "%{actor} deactivated %{target}"
172 user_reactivate: "%{actor} reactivated %{target}" 173 user_reactivate: "%{actor} reactivated %{target}"
174 redaktion_grant: "%{actor} added %{target} to Redaktion"
175 redaktion_revoke: "%{actor} removed %{target} from Redaktion"
173 176
174 open_gallery: "Open gallery" 177 open_gallery: "Open gallery"
175 asset_licenses: 178 asset_licenses:
@@ -235,6 +238,12 @@ en:
235 deactivate: "Deactivate" 238 deactivate: "Deactivate"
236 reactivate: "Reactivate" 239 reactivate: "Reactivate"
237 confirm_deactivate: "Deactivate %{login}? Sign-in is refused immediately; attributions are preserved." 240 confirm_deactivate: "Deactivate %{login}? Sign-in is refused immediately; attributions are preserved."
241 grant_redaktion: "Add to Redaktion"
242 revoke_redaktion: "Remove from Redaktion"
243 confirm_grant_redaktion: "Add %{login} to Redaktion? They will be able to publish in the protected sections."
244 confirm_revoke_redaktion: "Remove %{login} from Redaktion?"
245 needs_otp: "no second factor"
246
238 index: 247 index:
239 title: "User accounts" 248 title: "User accounts"
240 create_editor: "Create editor account" 249 create_editor: "Create editor account"
@@ -245,6 +254,7 @@ en:
245 group_editor: "Editors" 254 group_editor: "Editors"
246 group_alumni: "Alumni" 255 group_alumni: "Alumni"
247 group_empty: "— none —" 256 group_empty: "— none —"
257 admin_hint: "Administrative rights can only be granted after the account has signed in and set up a second factor."
248 labels: 258 labels:
249 roles: "Roles" 259 roles: "Roles"
250 roles: 260 roles:
@@ -554,6 +564,11 @@ en:
554 deactivated: "%{login} is now an alumnus and can no longer sign in." 564 deactivated: "%{login} is now an alumnus and can no longer sign in."
555 reactivated: "%{login} can sign in again." 565 reactivated: "%{login} can sign in again."
556 cannot_deactivate_self: "You cannot deactivate your own account." 566 cannot_deactivate_self: "You cannot deactivate your own account."
567 redaktion_granted: "%{login} is now part of Redaktion."
568 redaktion_revoked: "%{login} is no longer part of Redaktion."
569 redaktion_needs_otp: "%{login} needs a second factor first."
570 redaktion_not_self: "You cannot give up your own Redaktion role."
571
557 assets: 572 assets:
558 created: "Asset was successfully created." 573 created: "Asset was successfully created."
559 updated: "Asset was successfully updated." 574 updated: "Asset was successfully updated."
diff --git a/config/routes.rb b/config/routes.rb
index 58e1e632..d19ac25b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -102,6 +102,8 @@ Cccms::Application.routes.draw do
102 put :reset_otp 102 put :reset_otp
103 put :deactivate 103 put :deactivate
104 put :reactivate 104 put :reactivate
105 put :grant_redaktion
106 put :revoke_redaktion
105 end 107 end
106 end 108 end
107 resource :otp_enrollment, :only => [:show, :create, :update, :destroy] 109 resource :otp_enrollment, :only => [:show, :create, :update, :destroy]
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index b6f0970d..d7d8b9a6 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -6,7 +6,7 @@ class UsersControllerTest < ActionController::TestCase
6 login_as :quentin 6 login_as :quentin
7 get :index 7 get :index
8 assert_redirected_to admin_path 8 assert_redirected_to admin_path
9 assert_equal I18n.t("flash.common.admin_required"), flash[:error] 9 assert_equal I18n.t("flash.common.redaktion_required"), flash[:error]
10 end 10 end
11 11
12 test "get index as admin shows every group with per-row actions" do 12 test "get index as admin shows every group with per-row actions" do
@@ -53,7 +53,7 @@ class UsersControllerTest < ActionController::TestCase
53 assert !User.last.admin 53 assert !User.last.admin
54 end 54 end
55 55
56 test "creating new admin users being logged in as admin" do 56 test "creating a Redaktion account" do
57 login_as :aaron 57 login_as :aaron
58 elevate_session! 58 elevate_session!
59 assert_difference "User.count", +1 do 59 assert_difference "User.count", +1 do
@@ -63,13 +63,14 @@ class UsersControllerTest < ActionController::TestCase
63 :email => "foo@bar.com", 63 :email => "foo@bar.com",
64 :password => "xxxzzz", 64 :password => "xxxzzz",
65 :password_confirmation => "xxxzzz", 65 :password_confirmation => "xxxzzz",
66 :roles => ["admin", "redaktion"] 66 :roles => ["redaktion"]
67 } 67 }
68 } 68 }
69 end 69 end
70 70
71 assert_redirected_to user_path(User.last) 71 assert_redirected_to user_path(User.last)
72 assert User.last.admin 72 assert User.last.redaktion?
73 assert_not User.last.is_admin?
73 end 74 end
74 75
75 test "creating new users not being logged as regular user wont work" do 76 test "creating new users not being logged as regular user wont work" do
@@ -196,6 +197,7 @@ class UsersControllerTest < ActionController::TestCase
196 login_as :aaron 197 login_as :aaron
197 elevate_session! 198 elevate_session!
198 user = users(:quentin) 199 user = users(:quentin)
200 user.update_column(:otp_secret, ROTP::Base32.random)
199 put :update, params: { :id => user.id, :user => {:roles => ["admin", "redaktion"]} } 201 put :update, params: { :id => user.id, :user => {:roles => ["admin", "redaktion"]} }
200 202
201 assert_equal true, user.reload.is_admin? 203 assert_equal true, user.reload.is_admin?
@@ -261,4 +263,14 @@ class UsersControllerTest < ActionController::TestCase
261 263
262 assert_not user.reload.is_admin? 264 assert_not user.reload.is_admin?
263 end 265 end
266
267 test "an account without a second factor cannot be promoted to admin" do
268 login_as :aaron
269 elevate_session!
270 user = users(:quentin)
271
272 put :update, params: { :id => user.id, :user => { :roles => ["admin"] } }
273
274 assert_not user.reload.is_admin?
275 end
264end 276end