Skip to content

Commit c70562a

Browse files
committed
Add token circuit breaker notification (issue #18886)
When a Token::Workflow fails to deliver to the SCM with authorization errors ('Unauthorized request' or 'Request forbidden'), the token gets disabled. This change adds: - Event::TokenDisabled: New event triggered when token is disabled - NotificationToken: Model for rendering token-related notifications - Notification system: Sends notifications to token executor and members - Default subscriptions: Enabled by default for instant_email and web channels - Tests: Added comprehensive test coverage for the new functionality - Proper brand names: GitHub, GitLab, Gitea displayed correctly - Safe token lookup: Uses find_by instead of find to prevent exceptions The notification helps users immediately understand why their token was disabled and take corrective action. Fixes #18886
1 parent 3f69ef7 commit c70562a

File tree

8 files changed

+328
-2
lines changed

8 files changed

+328
-2
lines changed

src/api/app/models/event/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def notification_events
2222
Event::CommentForRequest, Event::CommentForReport,
2323
Event::RelationshipCreate, Event::RelationshipDelete,
2424
Event::Report, Event::Decision, Event::AppealCreated,
25-
Event::WorkflowRunFail,
25+
Event::WorkflowRunFail, Event::TokenDisabled,
2626
Event::AddedUserToGroup, Event::RemovedUserFromGroup,
2727
Event::Assignment]
2828
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
module Event
2+
class TokenDisabled < Base
3+
self.description = 'SCM/CI Token disabled due to authorization failure'
4+
payload_keys :id, :token_id, :scm_vendor, :summary, :token_description
5+
6+
receiver_roles :token_executor, :token_member
7+
delegate :members, to: :token, prefix: true
8+
9+
self.notification_explanation = 'Receive notifications when an SCM/CI integration token is disabled due to authorization problems.'
10+
11+
# Example of subject:
12+
# GitHub workflow token disabled
13+
def subject
14+
vendor_map = { 'github' => 'GitHub', 'gitlab' => 'GitLab', 'gitea' => 'Gitea' }
15+
vendor = vendor_map[payload['scm_vendor']] || payload['scm_vendor']&.capitalize
16+
"#{vendor} workflow token disabled"
17+
end
18+
19+
def token_executors
20+
[token&.executor].compact
21+
end
22+
23+
def parameters_for_notification
24+
super.merge(notifiable_type: 'Token::Workflow', notifiable_id: payload['token_id'], type: 'NotificationToken')
25+
end
26+
27+
def event_object
28+
Token.find_by(id: payload['token_id'])
29+
end
30+
31+
private
32+
33+
def token
34+
Token.find_by(id: payload['token_id'], type: 'Token::Workflow')
35+
end
36+
end
37+
end
38+
39+
# == Schema Information
40+
#
41+
# Table name: events
42+
#
43+
# id :bigint not null, primary key
44+
# eventtype :string(255) not null, indexed
45+
# mails_sent :boolean default(FALSE), indexed
46+
# payload :text(16777215)
47+
# undone_jobs :integer default(0)
48+
# created_at :datetime indexed
49+
# updated_at :datetime
50+
#
51+
# Indexes
52+
#
53+
# index_events_on_created_at (created_at)
54+
# index_events_on_eventtype (eventtype)
55+
# index_events_on_mails_sent (mails_sent)
56+
#
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
class NotificationToken < Notification
2+
def description
3+
"Token #{notifiable.description.presence || 'Token'} was disabled"
4+
end
5+
6+
def excerpt
7+
event_payload['summary'] || 'Token was disabled due to authorization failure'
8+
end
9+
10+
def avatar_objects
11+
[notifiable&.executor].compact
12+
end
13+
14+
def link_text
15+
'Token'
16+
end
17+
18+
def link_path
19+
return if notifiable.blank?
20+
21+
Rails.application.routes.url_helpers.token_path(notifiable)
22+
end
23+
end
24+
25+
# == Schema Information
26+
#
27+
# Table name: notifications
28+
#
29+
# id :bigint not null, primary key
30+
# bs_request_oldstate :string(255)
31+
# bs_request_state :string(255)
32+
# delivered :boolean default(FALSE), indexed
33+
# event_payload :text(16777215) not null
34+
# event_type :string(255) not null, indexed
35+
# last_seen_at :datetime
36+
# notifiable_type :string(255) indexed => [notifiable_id]
37+
# rss :boolean default(FALSE), indexed
38+
# subscriber_type :string(255) indexed => [subscriber_id]
39+
# subscription_receiver_role :string(255) not null
40+
# title :string(255)
41+
# type :string(255) indexed
42+
# web :boolean default(FALSE), indexed
43+
# created_at :datetime not null, indexed
44+
# updated_at :datetime not null
45+
# notifiable_id :integer indexed => [notifiable_type]
46+
# subscriber_id :integer indexed => [subscriber_type]
47+
#
48+
# Indexes
49+
#
50+
# index_notifications_on_created_at (created_at)
51+
# index_notifications_on_delivered (delivered)
52+
# index_notifications_on_event_type (event_type)
53+
# index_notifications_on_notifiable_type_and_notifiable_id (notifiable_type,notifiable_id)
54+
# index_notifications_on_rss (rss)
55+
# index_notifications_on_subscriber_type_and_subscriber_id (subscriber_type,subscriber_id)
56+
# index_notifications_on_type (type)
57+
# index_notifications_on_web (web)
58+
#

src/api/app/models/workflow_run.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,16 @@ def update_as_failed(message)
8181
# "Failed to report back to GitHub: Unauthorized request. Please check your credentials again."
8282
# "Failed to report back to GitHub: Request is forbidden."
8383

84-
token.update(enabled: false) if message.include?('Unauthorized request') || /Request (is )?forbidden/.match?(message)
84+
return unless message.include?('Unauthorized request') || /Request (is )?forbidden/.match?(message)
85+
86+
token.update(enabled: false)
87+
# Create event notification for token being disabled
88+
Event::TokenDisabled.create(
89+
token_id: token.id,
90+
scm_vendor: scm_vendor,
91+
summary: message,
92+
token_description: token.description
93+
)
8594
end
8695

8796
# Stores debug info to help figure out what went wrong when trying to save a Status in the SCM.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
class CreateDefaultTokenDisabledSubscriptions < ActiveRecord::Migration[7.2]
4+
def up
5+
# Create default subscriptions for Event::TokenDisabled
6+
# This event is triggered when a workflow token is disabled due to authorization failures
7+
create_default_subscription('token_executor', :instant_email)
8+
create_default_subscription('token_executor', :web)
9+
create_default_subscription('token_member', :instant_email)
10+
create_default_subscription('token_member', :web)
11+
end
12+
13+
def down
14+
raise ActiveRecord::IrreversibleMigration
15+
end
16+
17+
private
18+
19+
def create_default_subscription(receiver_role, channel)
20+
EventSubscription.find_or_create_by!(
21+
eventtype: 'Event::TokenDisabled',
22+
receiver_role: receiver_role,
23+
channel: channel,
24+
user_id: nil,
25+
group_id: nil
26+
) do |subscription|
27+
subscription.enabled = true
28+
end
29+
end
30+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
RSpec.describe Event::TokenDisabled do
2+
describe '#token_executors' do
3+
subject { event.token_executors }
4+
5+
let(:token) { create(:workflow_token) }
6+
let(:event) do
7+
Event::TokenDisabled.create(
8+
token_id: token.id,
9+
scm_vendor: 'github',
10+
summary: 'Failed to report back to GitHub: Unauthorized request.',
11+
token_description: 'My workflow token'
12+
)
13+
end
14+
15+
it { expect(subject).to contain_exactly(token.executor) }
16+
17+
context 'when the token does not exist' do
18+
before do
19+
event
20+
token.destroy
21+
end
22+
23+
it { expect(subject).to be_empty }
24+
end
25+
end
26+
27+
describe '#subject' do
28+
subject { event.subject }
29+
30+
let(:token) { create(:workflow_token) }
31+
32+
context 'with GitHub vendor' do
33+
let(:event) do
34+
Event::TokenDisabled.create(
35+
token_id: token.id,
36+
scm_vendor: 'github',
37+
summary: 'Failed to report back to GitHub: Unauthorized request.',
38+
token_description: 'My workflow token'
39+
)
40+
end
41+
42+
it { expect(subject).to eq('GitHub workflow token disabled') }
43+
end
44+
45+
context 'with GitLab vendor' do
46+
let(:event) do
47+
Event::TokenDisabled.create(
48+
token_id: token.id,
49+
scm_vendor: 'gitlab',
50+
summary: 'Failed to report back to GitLab: Request forbidden.',
51+
token_description: 'My workflow token'
52+
)
53+
end
54+
55+
it { expect(subject).to eq('GitLab workflow token disabled') }
56+
end
57+
end
58+
end
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
RSpec.describe NotificationToken do
2+
describe '#description' do
3+
subject { notification.description }
4+
5+
let(:token) { create(:workflow_token, description: 'My API Token') }
6+
let(:notification) do
7+
create(:notification, type: 'NotificationToken', notifiable: token)
8+
end
9+
10+
it { expect(subject).to include('My API Token') }
11+
it { expect(subject).to include('disabled') }
12+
13+
context 'when token has no description' do
14+
let(:token) { create(:workflow_token, description: '') }
15+
16+
it { expect(subject).to include('Token') }
17+
end
18+
end
19+
20+
describe '#excerpt' do
21+
subject { notification.excerpt }
22+
23+
let(:token) { create(:workflow_token) }
24+
let(:event_payload) do
25+
{ 'summary' => 'Failed to report back to GitHub: Unauthorized request.' }
26+
end
27+
let(:notification) do
28+
create(:notification, type: 'NotificationToken', notifiable: token, event_payload: event_payload)
29+
end
30+
31+
it { expect(subject).to eq('Failed to report back to GitHub: Unauthorized request.') }
32+
33+
context 'when summary is missing' do
34+
let(:event_payload) { {} }
35+
36+
it { expect(subject).to eq('Token was disabled due to authorization failure') }
37+
end
38+
end
39+
40+
describe '#avatar_objects' do
41+
subject { notification.avatar_objects }
42+
43+
let(:token) { create(:workflow_token) }
44+
let(:notification) do
45+
create(:notification, type: 'NotificationToken', notifiable: token)
46+
end
47+
48+
it { expect(subject).to contain_exactly(token.executor) }
49+
50+
context 'when token is deleted' do
51+
before { token.destroy }
52+
53+
it { expect(subject).to be_empty }
54+
end
55+
end
56+
57+
describe '#link_text' do
58+
subject { notification.link_text }
59+
60+
let(:token) { create(:workflow_token) }
61+
let(:notification) do
62+
create(:notification, type: 'NotificationToken', notifiable: token)
63+
end
64+
65+
it { expect(subject).to eq('Token') }
66+
end
67+
68+
describe '#link_path' do
69+
subject { notification.link_path }
70+
71+
let(:token) { create(:workflow_token) }
72+
let(:notification) do
73+
create(:notification, type: 'NotificationToken', notifiable: token)
74+
end
75+
76+
it { expect(subject).to eq(Rails.application.routes.url_helpers.token_path(token)) }
77+
78+
context 'when notifiable is blank' do
79+
let(:notification) do
80+
create(:notification, type: 'NotificationToken', notifiable: nil)
81+
end
82+
83+
it { expect(subject).to be_nil }
84+
end
85+
end
86+
end

src/api/spec/models/workflow_run_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,35 @@
9898
it 'disables the token of the token workflow' do
9999
expect { subject }.to change { workflow_run.token.reload.enabled }.from(true).to(false)
100100
end
101+
102+
it 'creates a TokenDisabled event' do
103+
expect { subject }.to change(Event::TokenDisabled, :count).by(1)
104+
end
105+
106+
it 'stores token_id in the TokenDisabled event payload' do
107+
subject
108+
event = Event::TokenDisabled.last
109+
expect(event.payload['token_id']).to eq(workflow_run.token.id)
110+
end
111+
112+
it 'stores scm_vendor and summary in the TokenDisabled event payload' do
113+
subject
114+
event = Event::TokenDisabled.last
115+
expect(event.payload['scm_vendor']).to eq(workflow_run.scm_vendor)
116+
expect(event.payload['summary']).to include('Request is forbidden')
117+
end
118+
end
119+
120+
context 'when the SCM responds with an unauthorized message' do
121+
subject { workflow_run.save_scm_report_failure('Failed to report back to GitLab: Unauthorized request. Please check your credentials again.', { api_endpoint: 'https://gitlab.com/api/v4' }) }
122+
123+
it 'disables the token of the token workflow' do
124+
expect { subject }.to change { workflow_run.token.reload.enabled }.from(true).to(false)
125+
end
126+
127+
it 'creates a TokenDisabled event' do
128+
expect { subject }.to change(Event::TokenDisabled, :count).by(1)
129+
end
101130
end
102131
end
103132

0 commit comments

Comments
 (0)