Project

General

Profile

Feature #44337 » webhooks_admin_page.patch

Vincent Robert, 2026-08-12 14:57

View differences:

app/controllers/admin_controller.rb
22 22
  self.main_menu = false
23 23
  menu_item :projects, :only => :projects
24 24
  menu_item :plugins, :only => :plugins
25
  menu_item :webhooks, :only => :webhooks
25 26
  menu_item :info, :only => :info
26 27

  
27 28
  before_action :require_admin
......
48 49
    @plugins = Redmine::Plugin.all
49 50
  end
50 51

  
52
  def webhooks
53
    @webhooks = Webhook.eager_load(:user).order(*User.fields_for_order_statement, :url)
54
  end
55

  
51 56
  # Loads the default configuration
52 57
  # (roles, trackers, statuses, workflow, enumerations)
53 58
  def default_configuration
app/controllers/webhooks_controller.rb
20 20
class WebhooksController < ApplicationController
21 21
  self.main_menu = false
22 22

  
23
  ADMIN_CUSTODY_ACTIONS = %w(edit update destroy).freeze
24

  
23 25
  before_action :require_login
24 26
  before_action :check_enabled
25 27
  before_action :authorize
......
28 30

  
29 31
  require_sudo_mode :create, :update, :destroy
30 32

  
33
  helper_method :secret_hidden?
34

  
31 35
  def index
32 36
    @webhooks = webhooks.order(:url)
33 37
  end
......
42 46
  def create
43 47
    @webhook = webhooks.build(webhook_params)
44 48
    if @webhook.save
45
      redirect_to webhooks_path
49
      redirect_back_or_default webhooks_path
46 50
    else
47 51
      render :new
48 52
    end
......
50 54

  
51 55
  def update
52 56
    if @webhook.update(webhook_params)
53
      redirect_to webhooks_path
57
      redirect_back_or_default webhooks_path
54 58
    else
55 59
      render :edit
56 60
    end
......
58 62

  
59 63
  def destroy
60 64
    @webhook.destroy
61
    redirect_to webhooks_path
65
    redirect_back_or_default webhooks_path
62 66
  end
63 67

  
64 68
  private
65 69

  
70
  # True when the stored secret must not be disclosed to the current user
71
  def secret_hidden?
72
    @webhook&.persisted? && @webhook.user != User.current
73
  end
74

  
66 75
  def webhook_params
67
    params.require(:webhook).permit(:url, :secret, :active, events: [], project_ids: [])
76
    attrs = params.require(:webhook).permit(:url, :secret, :active, events: [], project_ids: [])
77
    attrs.delete(:secret) if secret_hidden? && attrs[:secret].blank?
78
    attrs
68 79
  end
69 80

  
70 81
  def find_webhook
71
    @webhook = webhooks.find(params[:id])
82
    @webhook = editable_webhooks.find(params[:id])
72 83
  rescue ActiveRecord::RecordNotFound
73 84
    render_404
74 85
  end
......
77 88
    User.current.webhooks
78 89
  end
79 90

  
91
  # Administrators may edit any webhook, without ever becoming its owner
92
  def editable_webhooks
93
    User.current.admin? ? Webhook.all : webhooks
94
  end
95

  
80 96
  def authorize
81 97
    deny_access unless User.current.allowed_to?(:use_webhooks, nil, global: true)
82 98
  end
83 99

  
84 100
  def check_enabled
85
    render_403 unless Webhook.enabled?
101
    return if Webhook.enabled?
102
    return if User.current.admin? && ADMIN_CUSTODY_ACTIONS.include?(action_name)
103

  
104
    render_403
86 105
  end
87 106
end
app/models/webhook.rb
133 133

  
134 134
  def setable_projects
135 135
    user = self.user || User.current
136
    Project.visible(user).to_a.select{|p| user.allowed_to?(:use_webhooks, p)}
136
    Project.visible(user).allowed_to(user, :use_webhooks).to_a
137 137
  end
138 138

  
139 139
  def setable_events
app/views/admin/webhooks.html.erb
1
<% if Webhook.enabled? %>
2
<div class="contextual">
3
  <%= link_to sprite_icon('add', l(:label_webhook_new)), new_webhook_path(:back_url => admin_webhooks_path), class: 'icon icon-add' %>
4
</div>
5
<% end %>
6

  
7
<%= title l(:label_webhook_plural) %>
8

  
9
<% unless Webhook.enabled? %>
10
  <p class="warning"><%= t(:webhook_disabled_info_html, :link => link_to(l(:label_integrations), settings_path(:tab => 'integrations'))) %></p>
11
<% end %>
12

  
13
<%= render :partial => 'webhooks/list', :locals => { :webhooks => @webhooks, :show_author => true, :back_url => admin_webhooks_path } %>
app/views/webhooks/_form.html.erb
3 3
<div class="splitcontent">
4 4
  <div class="splitcontentleft">
5 5
    <div class="box tabular">
6
      <% if secret_hidden? %>
7
        <p><label for="webhook_user"><%= l :field_user %></label><%= text_field_tag 'webhook_user', @webhook.user.name, disabled: true %></p>
8
      <% end %>
6 9
      <p><%= f.text_field :url, required: true, size: 60 %>
7 10
        <em class="info"><%= l :webhook_url_info %></em>
8 11
      </p>
9 12
      <p>
10
        <%= f.text_field :secret %>
13
        <%= f.text_field :secret, :value => (secret_hidden? ? '' : @webhook.secret) %>
11 14
        <em class="info"><%= raw l :webhook_secret_info_html %></em>
15
        <% if secret_hidden? %><em class="info"><%= l :webhook_secret_keep_info %></em><% end %>
12 16
      </p>
13 17
      <p><%= f.check_box :active %></p>
14 18
    </div>
app/views/webhooks/_list.html.erb
1
<% if webhooks.any? %>
2
<div class="autoscroll">
3
<table class="list webhooks">
4
  <thead><tr>
5
    <% if show_author %><th><%= l :field_user %></th><% end %>
6
    <th><%= l :field_active %></th>
7
    <th><%= l :field_url %></th>
8
    <th><%= l :label_webhook_events %></th>
9
    <th><%= l :label_project_plural %></th>
10
    <th></th>
11
  </tr></thead>
12
  <tbody>
13
  <% webhooks.each do |webhook| %>
14
    <% link_params = back_url.present? ? {:back_url => back_url} : {} %>
15
    <tr id="webhook_<%= webhook.id %>" class="<%= cycle("odd", "even") %>">
16
      <% if show_author %><td><%= link_to_user webhook.user %></td><% end %>
17
      <td><%= webhook.active ? l(:general_text_Yes) : l(:general_text_No) %></td>
18
      <td title="<%= webhook.url %>"><%= truncate webhook.url, length: 40 %></td>
19
      <td><%= safe_join webhook.events.map{|e| content_tag :code, e }, ', ' %></td>
20
      <td><%= safe_join webhook.projects.visible.map{|p| link_to_project(p) }, ', ' %></td>
21
      <td class="buttons">
22
        <%= link_to sprite_icon('edit', l(:button_edit)), edit_webhook_path(webhook, link_params), class: 'icon icon-edit' %>
23
        <%= link_to sprite_icon('del', l(:button_delete)), webhook_path(webhook, link_params), :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :class => 'icon icon-del' %>
24
      </td>
25
    </tr>
26
  <% end %>
27
  </tbody>
28
</table>
29
</div>
30
<% else %>
31
  <p class="nodata"><%= l(:label_no_data) %></p>
32
<% end %>
app/views/webhooks/edit.html.erb
2 2

  
3 3
<%= labelled_form_for @webhook, html: { method: :patch } do |f| %>
4 4
  <%= render :partial => 'form', :locals => { :f => f } %>
5
  <%= back_url_hidden_field_tag %>
5 6
  <%= submit_tag l(:button_save) %>
7
  <%= cancel_button_tag webhooks_path %>
6 8
<% end %>
app/views/webhooks/index.html.erb
4 4

  
5 5
<%= title l :label_webhook_plural %>
6 6

  
7
<% if @webhooks.any? %>
8
<div class="autoscroll">
9
<table class="list">
10
  <thead><tr>
11
    <th><%= l :field_active %></th>
12
    <th><%= l :field_url %></th>
13
    <th><%= l :label_webhook_events %></th>
14
    <th><%= l :label_project_plural %></th>
15
    <th></th>
16
  </tr></thead>
17
  <tbody>
18
  <% @webhooks.each do |webhook| %>
19
    <tr id="webhook_<%= webhook.id %>" class="<%= cycle("odd", "even") %>">
20
      <td><%= webhook.active ? l(:general_text_Yes) : l(:general_text_No) %></td>
21
      <td><%= truncate webhook.url, length: 40 %></td>
22
      <td><%= safe_join webhook.events.map{|e| content_tag :code, e }, ', ' %></td>
23
      <td><%= safe_join webhook.projects.visible.map{|p| link_to_project(p) }, ', ' %></td>
24
      <td class="buttons">
25
        <%= link_to sprite_icon('edit', l(:button_edit)), edit_webhook_path(webhook), class: 'icon icon-edit' %>
26
        <%= link_to sprite_icon('del', l(:button_delete)), webhook_path(webhook), :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :class => 'icon icon-del' %>
27
      </td>
28
    </tr>
29
  <% end %>
30
  </tbody>
31
</table>
32
</div>
33
<% else %>
34
  <p class="nodata"><%= l(:label_no_data) %></p>
35
<% end %>
7
<%= render :partial => 'webhooks/list', :locals => { :webhooks => @webhooks, :show_author => false, :back_url => nil } %>
app/views/webhooks/new.html.erb
2 2

  
3 3
<%= labelled_form_for @webhook, url: webhooks_path do |f| %>
4 4
  <%= render :partial => 'webhooks/form', locals: { f: f } %>
5
  <%= back_url_hidden_field_tag %>
5 6
  <%= submit_tag l(:button_create) %>
6
  <%= link_to l(:button_cancel), webhooks_path %>
7
  <%= cancel_button_tag webhooks_path %>
7 8
<% end %>
config/locales/en.yml
1197 1197
  webhook_event_deleted: "%{object_name} deleted"
1198 1198
  webhook_url_info: Redmine will send a POST request to this URL whenever one of the selected events occurs in one of the selected projects.
1199 1199
  webhook_secret_info_html: If provided, Redmine will use this to create a hash signature that is sent with each delivery as the value of the X-Redmine-Signature-256 header.
1200
  webhook_secret_keep_info: The secret of another user is not displayed. Leave this field blank to keep it unchanged.
1201
  webhook_disabled_info_html: Webhooks are disabled, so none of the webhooks below is triggered. They are still listed here so that they can be reviewed or removed. Webhooks can be enabled in the %{link} settings.
1200 1202

  
1201 1203
  button_login: Login
1202 1204
  button_submit: Submit
config/locales/fr.yml
1508 1508
    selected events occurs in one of the selected projects.
1509 1509
  webhook_secret_info_html: If provided, Redmine will use this to create a hash signature
1510 1510
    that is sent with each delivery as the value of the X-Redmine-Signature-256 header.
1511
  webhook_secret_keep_info: "Le secret d'un autre utilisateur n'est pas affiché. Laisser
1512
    ce champ vide pour le conserver."
1513
  webhook_disabled_info_html: "Les webhooks sont désactivés : aucun des webhooks ci-dessous
1514
    n'est déclenché. Ils restent listés ici afin de pouvoir être examinés ou supprimés.
1515
    Les webhooks peuvent être activés dans la configuration %{link}."
1511 1516
  setting_webhooks_enabled: Enable webhooks
1512 1517
  label_integrations: Integrations
1513 1518
  label_alert_note: Note
config/routes.rb
380 380
  get 'admin', :to => 'admin#index'
381 381
  get 'admin/projects', :to => 'admin#projects'
382 382
  get 'admin/plugins', :to => 'admin#plugins'
383
  get 'admin/webhooks', :to => 'admin#webhooks'
383 384
  get 'admin/info', :to => 'admin#info'
384 385
  post 'admin/test_email', :to => 'admin#test_email', :as => 'test_email'
385 386
  post 'admin/default_configuration', :to => 'admin#default_configuration'
lib/redmine/preparation.rb
293 293
                  :caption => :'doorkeeper.layouts.admin.nav.applications',
294 294
                  :icon => 'apps',
295 295
                  :html => {:class => 'icon icon-applications'}
296
        menu.push :webhooks, {:controller => 'admin', :action => 'webhooks'},
297
                  :caption => :label_webhook_plural,
298
                  :icon => 'webhook',
299
                  :html => {:class => 'icon icon-webhook'}
296 300
        menu.push :plugins, {:controller => 'admin', :action => 'plugins'},
297 301
                  :last => true,
298 302
                  :icon => 'plugins',
test/functional/admin_controller_test.rb
168 168
    end
169 169
  end
170 170

  
171
  def test_webhooks
172
    hook = Webhook.create!(:url => 'https://example.com/dlopper/hook', :user => User.find_by_login('dlopper'),
173
                           :events => %w(issue.created), :projects => [Project.find(1)])
174
    other_hook = Webhook.create!(:url => 'https://example.com/admin/hook', :user => User.find(1),
175
                                 :events => %w(issue.updated), :projects => [Project.find(1)])
176

  
177
    with_settings :webhooks_enabled => '1' do
178
      get :webhooks
179
    end
180
    assert_response :success
181

  
182
    assert_select "tr#webhook_#{hook.id}" do
183
      assert_select 'td', :text => hook.user.name
184
      assert_select 'td', :text => hook.url
185
      assert_select "td.buttons a[href=?]", "/webhooks/#{hook.id}/edit?back_url=%2Fadmin%2Fwebhooks"
186
    end
187
    assert_select "tr#webhook_#{other_hook.id}"
188
    assert_select 'div.contextual a[href=?]', '/webhooks/new?back_url=%2Fadmin%2Fwebhooks'
189
  end
190

  
191
  def test_webhooks_should_remain_accessible_without_creation_link_when_disabled
192
    Webhook.create!(:url => 'https://example.com/dlopper/hook', :user => User.find_by_login('dlopper'),
193
                    :events => %w(issue.created), :projects => [Project.find(1)])
194

  
195
    with_settings :webhooks_enabled => '0' do
196
      get :webhooks
197
      assert_response :success
198
      assert_select 'table.webhooks tr[id^=?]', 'webhook_'
199
      assert_select 'div.contextual a', 0
200
      assert_select 'p.warning a[href=?]', '/settings?tab=integrations'
201
    end
202
  end
203

  
204
  def test_webhooks_should_be_denied_to_non_admin_users
205
    @request.session[:user_id] = 2
206
    get :webhooks
207
    assert_response :forbidden
208
  end
209

  
171 210
  def test_info
172 211
    get :info
173 212
    assert_response :success
test/functional/webhooks_controller_test.rb
62 62
  test "should get new" do
63 63
    get :new
64 64
    assert_response :success
65
    assert_select 'em.info', text: I18n.t(:webhook_secret_keep_info), count: 0
65 66
  end
66 67

  
67 68
  test "should create webhook" do
......
74 75
  test "should get edit" do
75 76
    get :edit, params: { id: @hook.id }
76 77
    assert_response :success
77 78
  end
78 79

  
79 80
  test "should update webhook" do
......
87 97
    assert_response :not_found
88 98
  end
89 99

  
100
  test 'index should not list hooks of other users to admins' do
101
    admin_hook = @other_hook
102
    dlopper_hook = @hook
103
    login_as_admin
104
    get :index
105
    assert_response :success
106
    assert_select 'td', text: admin_hook.url
107
    assert_select 'td', text: dlopper_hook.url, count: 0
108
  end
109

  
110
  test 'admin should edit hook of other user' do
111
    login_as_admin
112
    get :edit, params: { id: @hook.id }
113
    assert_response :success
114
    assert_select 'input#webhook_user[disabled][value=?]', @dlopper.name
115
  end
116

  
117
  test 'edit should not show the owner of ones own hook' do
118
    get :edit, params: { id: @hook.id }
119
    assert_response :success
120
    assert_select 'input#webhook_user', count: 0
121
  end
122

  
123
  test 'admin should update hook of other user without becoming its owner' do
124
    login_as_admin
125
    patch :update, params: { id: @hook.id, webhook: { url: 'https://example.com/fixed/hook' } }
126
    assert_redirected_to webhooks_path
127
    @hook.reload
128
    assert_equal 'https://example.com/fixed/hook', @hook.url
129
    assert_equal @dlopper, @hook.user
130
  end
131

  
132
  test 'admin should deactivate hook of other user' do
133
    @hook.update_column :active, true
134
    login_as_admin
135
    patch :update, params: { id: @hook.id, webhook: { active: '0' } }
136
    assert_not @hook.reload.active
137
  end
138

  
139
  test 'admin should destroy hook of other user' do
140
    login_as_admin
141
    assert_difference 'Webhook.count', -1 do
142
      delete :destroy, params: { id: @hook.id }
143
    end
144
  end
145

  
146
  test 'create should redirect to back_url' do
147
    post :create, params: { webhook: { url: 'https://example.com/new/hook', events: %w(issue.created), project_ids: [@project.id] }, back_url: '/admin/webhooks' }
148
    assert_redirected_to '/admin/webhooks'
149
  end
150

  
151
  test 'update should redirect to back_url' do
152
    login_as_admin
153
    patch :update, params: { id: @hook.id, webhook: { url: 'https://example.com/fixed/hook' }, back_url: '/admin/webhooks' }
154
    assert_redirected_to '/admin/webhooks'
155
  end
156

  
157
  test 'edit should not disclose secret of other user' do
158
    @hook.update_column :secret, 'v3rys3cret'
159
    login_as_admin
160
    get :edit, params: { id: @hook.id }
161
    assert_response :success
162
    assert_select 'input#webhook_secret'
163
    assert_not_include 'v3rys3cret', response.body
164
  end
165

  
166
  test 'update should keep secret of other user when submitted blank' do
167
    @hook.update_column :secret, 'v3rys3cret'
168
    login_as_admin
169
    patch :update, params: { id: @hook.id, webhook: { url: @hook.url, secret: '' } }
170
    assert_equal 'v3rys3cret', @hook.reload.secret
171
  end
172

  
173
  test 'update should replace secret of other user when a new one is submitted' do
174
    @hook.update_column :secret, 'v3rys3cret'
175
    login_as_admin
176
    patch :update, params: { id: @hook.id, webhook: { url: @hook.url, secret: 'newsecret' } }
177
    assert_equal 'newsecret', @hook.reload.secret
178
  end
179

  
180
  test 'owner should see and be able to clear their own secret' do
181
    @hook.update_column :secret, 'v3rys3cret'
182
    get :edit, params: { id: @hook.id }
183
    assert_select 'input#webhook_secret[value=?]', 'v3rys3cret'
184

  
185
    patch :update, params: { id: @hook.id, webhook: { url: @hook.url, secret: '' } }
186
    assert_equal '', @hook.reload.secret
187
  end
188

  
189
  test 'admin should keep access to existing hooks when disabled' do
190
    login_as_admin
191
    with_settings webhooks_enabled: '0' do
192
      get :edit, params: { id: @hook.id }
193
      assert_response :success
194

  
195
      patch :update, params: { id: @hook.id, webhook: { url: 'https://example.com/fixed/hook' } }
196
      assert_redirected_to webhooks_path
197

  
198
      assert_difference 'Webhook.count', -1 do
199
        delete :destroy, params: { id: @hook.id }
200
      end
201
    end
202
  end
203

  
204
  test 'admin should not create hooks when disabled' do
205
    login_as_admin
206
    with_settings webhooks_enabled: '0' do
207
      get :index
208
      assert_response :forbidden
209

  
210
      get :new
211
      assert_response :forbidden
212
    end
213
  end
214

  
90 215
  private
91 216

  
217
  def login_as_admin
218
    @request.session[:user_id] = User.find_by_login('admin').id
219
  end
220

  
92 221
  def create_hook(url: 'https://example.com/some/hook',
93 222
                  user: User.find_by_login('dlopper'),
94 223
                  events: %w(issue.created issue.updated),
test/integration/routing/admin_test.rb
24 24
    should_route 'GET /admin' => 'admin#index'
25 25
    should_route 'GET /admin/projects' => 'admin#projects'
26 26
    should_route 'GET /admin/plugins' => 'admin#plugins'
27
    should_route 'GET /admin/webhooks' => 'admin#webhooks'
27 28
    should_route 'GET /admin/info' => 'admin#info'
28 29
    should_route 'POST /admin/test_email' => 'admin#test_email'
29 30
    should_route 'POST /admin/default_configuration' => 'admin#default_configuration'
    (1-1/1)