diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-01 04:14:00 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-01 04:14:00 +0200 |
| commit | abd7ee1fc2ecc15b50944db30c59bedc26ec41b6 (patch) | |
| tree | 821bb8ca610664c57d9065bf62285166243500a4 /app | |
| parent | 6df48c1413a14516e7ee8919f33fbc13f0141966 (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.
Diffstat (limited to 'app')
| -rw-r--r-- | app/controllers/users_controller.rb | 30 | ||||
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 14 | ||||
| -rw-r--r-- | app/models/user.rb | 31 | ||||
| -rw-r--r-- | app/views/users/_user.html.erb | 17 | ||||
| -rw-r--r-- | app/views/users/index.html.erb | 1 |
5 files changed, 88 insertions, 5 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 | ||
| 286 | end | 298 | end |
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 | ||
| 216 | end | 247 | end |
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] || [] %> |
