Skip to content

Commit e544b96

Browse files
feat(ssid): migrate to rails credentials, ssid multipart upload api
1 parent 675bbeb commit e544b96

12 files changed

Lines changed: 119 additions & 50 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
# Ignore user-specific VSCode project files
1515
.vscode
1616

17+
# Ignore credentials files which may contain sensitive information
18+
/config/credentials/*.key
19+
/config/credentials/*.yml.enc
20+
!/config/credentials/test.key
21+
!/config/credentials/test.yml.enc
22+
1723
# Ignore the default SQLite database.
1824
/db/*.sqlite3
1925
/db/*.sqlite3-journal

app/services/course/assessment/submission/ssid_plagiarism_service.rb

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def fetch_plagiarism_result(limit, offset)
3232
end
3333

3434
def download_submission_pair_result(submission_pair_id)
35-
ssid_api_service = SsidAsyncApiService.new(
35+
ssid_api_service = SsidApiService.new(
3636
"submission-pairs/#{submission_pair_id}/report", {}
3737
)
3838
response_status, response_body = ssid_api_service.get
@@ -52,7 +52,7 @@ def share_assessment_result
5252
end
5353

5454
def fetch_plagiarism_check_result
55-
ssid_api_service = SsidAsyncApiService.new("folders/#{@main_assessment.ssid_folder_id}/plagiarism-checks", {})
55+
ssid_api_service = SsidApiService.new("folders/#{@main_assessment.ssid_folder_id}/plagiarism-checks", {})
5656
response_status, response_body = ssid_api_service.get
5757
raise SsidError, { status: response_status, body: response_body } unless response_status == 200
5858

@@ -67,13 +67,28 @@ def create_ssid_folders
6767
end
6868
end
6969

70+
def prepare_ssid_presigned_request(assessment)
71+
ssid_api_service = SsidApiService.new("folders/#{assessment.ssid_folder_id}/uploads", {})
72+
response_status, response_body = ssid_api_service.post
73+
raise SsidError, { status: response_status, body: response_body } unless response_status == 200
74+
75+
upload_url = response_body['payload']['data']['url']
76+
unless SsidApiService.upload_whitelist.match?(upload_url)
77+
raise SsidError, { status: 500, body: 'Invalid presigned URL' }
78+
end
79+
80+
[upload_url, response_body['payload']['data']['fields']]
81+
end
82+
7083
def run_upload_answers
7184
@linked_assessments.each do |assessment|
85+
upload_url, upload_fields = prepare_ssid_presigned_request(assessment)
7286
service = Course::Assessment::Submission::SsidZipDownloadService.new(assessment)
7387
zip_files = service.download_and_zip
74-
ssid_api_service = SsidAsyncApiService.new("folders/#{assessment.ssid_folder_id}/submissions", {})
88+
ssid_api_service = SsidApiService.new('', {}, upload_url)
7589
zip_files.each do |zip_file|
76-
response_status, response_body = ssid_api_service.post_multipart(zip_file)
90+
form_data = upload_fields.merge('file' => Faraday::Multipart::FilePart.new(zip_file, 'application/zip'))
91+
response_status, response_body = ssid_api_service.post_multipart(form_data)
7792
raise SsidError, { status: response_status, body: response_body } unless response_status == 204
7893
end
7994
ensure
@@ -82,7 +97,7 @@ def run_upload_answers
8297
end
8398

8499
def send_plagiarism_check_request
85-
ssid_api_service = SsidAsyncApiService.new("folders/#{@main_assessment.ssid_folder_id}/plagiarism-checks", {
100+
ssid_api_service = SsidApiService.new("folders/#{@main_assessment.ssid_folder_id}/plagiarism-checks", {
86101
comparedFolderIds: @linked_assessments.pluck(:ssid_folder_id)
87102
})
88103
response_status, response_body = ssid_api_service.post
@@ -94,7 +109,7 @@ def ssid_submission_to_submission_id(ssid_submission)
94109
end
95110

96111
def fetch_ssid_submission_pair_data(limit, offset)
97-
ssid_api_service = SsidAsyncApiService.new(
112+
ssid_api_service = SsidApiService.new(
98113
"folders/#{@main_assessment.ssid_folder_id}/plagiarism-checks/latest/submission-pairs",
99114
{ limit: limit, offset: offset }
100115
)
@@ -105,7 +120,7 @@ def fetch_ssid_submission_pair_data(limit, offset)
105120
end
106121

107122
def create_ssid_shared_resource_link(resource_type, resource_id)
108-
ssid_api_service = SsidAsyncApiService.new('shared-resources', {
123+
ssid_api_service = SsidApiService.new('shared-resources', {
109124
resourceType: resource_type,
110125
resourceId: resource_id
111126
})

app/services/course/ssid_folder_service.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ def initialize(folder_name, parent_folder_id = nil)
55
end
66

77
def run_create_ssid_folder_service
8-
ssid_api_service = SsidAsyncApiService.new('folders', @folder_object)
8+
ssid_api_service = SsidApiService.new('folders', @folder_object)
99
response_status, response_body = ssid_api_service.post
1010

1111
# If id is lost in our DB somehow, we can recover it if SSID returns a 409
Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
# frozen_string_literal: true
22

3-
class SsidAsyncApiService
4-
def config
5-
ENV.fetch('SSID_URL')
3+
class SsidApiService
4+
def self.api_url
5+
Rails.application.credentials.ssid.url
66
end
77

8-
def initialize(api_namespace, payload)
8+
# Validate that the URL is in the whitelist before allowing uploads to it.
9+
# This is to prevent leaking data in case the SSID response is intercepted.
10+
def self.upload_whitelist
11+
Regexp.new(Rails.application.credentials.ssid.upload_whitelist_pattern)
12+
end
13+
14+
def initialize(api_namespace, payload, url = nil)
915
@api_namespace = api_namespace
1016
@payload = payload
17+
@url = url || self.class.api_url
1118
end
1219

1320
def post
@@ -20,8 +27,7 @@ def post
2027
[500, nil]
2128
end
2229

23-
def post_multipart(file_path)
24-
form_data = { 'file' => Faraday::Multipart::FilePart.new(file_path, 'application/zip') }
30+
def post_multipart(form_data)
2531
response = connection.post(@api_namespace) do |req|
2632
req.body = form_data
2733
end
@@ -42,8 +48,10 @@ def get
4248
private
4349

4450
def connection
45-
@connection ||= Faraday.new(url: config) do |builder|
46-
builder.request :authorization, 'Bearer', -> { ENV.fetch('SSID_API_KEY', nil) }
51+
@connection ||= Faraday.new(url: @url) do |builder|
52+
if @url == self.class.api_url
53+
builder.request :authorization, 'Bearer', -> { Rails.application.credentials.ssid.api_key }
54+
end
4755
builder.request :multipart
4856
end
4957
end

config/credentials/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Each environment has two files:
1111
- `<environment>.key`, the decryption key that allows reading the `.yml.enc` file. **Keys from deployed environments (staging and production) must NEVER BE COMMITTED.** Once leaked, all secrets in the environment will be compromised.
1212
- `<environment>.yml.enc`, an encrypted YAML file containing sensitive environment data (e.g. API keys). While theoretically safe to commit because the data is unreadable without the decryption key, we currently do not commit files containing real sensitive data as an additional safety measure.
1313

14-
The `development` and `test` key files are checked into this repository, so local development works out of the box. The sample credentials use the same structure as staging/production but with redacted values, so external API integrations (e.g. Codaveri, AWS) will not function.
14+
The `test` key files are checked into this repository. The sample credentials use the same structure as other environments but with redacted values, so external API integrations (e.g. Codaveri, AWS) will not function.
1515

1616
If you are a Coursemology team member who needs working credentials, contact current staff for the appropriate credentials file.
1717

config/credentials/development.key

Lines changed: 0 additions & 1 deletion
This file was deleted.

config/credentials/development.yml.enc

Lines changed: 0 additions & 1 deletion
This file was deleted.

config/credentials/test.yml.enc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
22UauTnIrniRSCH/x4uVoj8HBcZJHb1ET04fxlhsYVsXPZ5vXG5mr+V5D5+F5iKv4pHNpAA7Osg6b/jWiG6t5uKPvlCwRL5j4aBi03bMIc5AMBH/8s2gEfvpWSZUC0viSNT4zwfJgKdgwHs4QhBg3EfkYxLe6S3R7W/RwX/hgW8pQB5Hw3bZhpbOtDuJeP/4Of4xg51PWP2hEz4K4IF9kTMKtjiGpprIoUQ0WFhe7l8cDBWsXBZdusCH4azQHkKo0A6YtuABe+Yaq5gBb7J2GqnVss89IJRp3TILfjgpFy8CrjGx1DpiY6csHFlvqbiOxJUjk+B0oqp2qIFOrGabcNEaGCT7tvNUEJwKb9WPYLTwfNW2EW4VgXAO7hM=--bb5c6OI9HMr/h3Jd--0p6ur3vWAdKRI3SYxGArog==
1+
ONmuV4CWoclCzxsWAbR+B0P9g7iT9wGrBGZBzNLfxp0S6lQ+kiJJrw8wV66vKDYxiODYdTFA4W+vL//7Dd3HAdMy1s+3qFxtEu0GvbFbHJDbu46uCQd/4UiVEpjHa0KeBwd2n6SpIUAZDmBbrtHoGKzxz1lP3M6vyd1g9D2vZguiExuH4JVDbF4jMYd4DQdlqLZL2Poa3r1Gwp/70mt4u2J+rNI1JshK0LFov9P3sAqWIDDbodaQXRDmp92eSNtdui5AmaeGtQjYryygE8njKE4OWhWAGmoRAk20hpiOZvvIc8DMtFxQQemrXZWFRdOkq2g/Lyy2MOMMSSM9dhoAOcPEjCEcSNDmkXddrefJPkEfWp+sXWC6590pbyx7Lvterl+u/bHMALhnUGdVkkKxrNVWAJ9xt1TOfR4TM6ThBZOPF8/x+VEnd0arhh3zW1pe8FJ2mZqFxGbL8VuOA/VPRzAszEq3u3bW5WY36AMIg7xgBplyBqQXDsTW7BCbSFHpX7Y+VwpEOj1lSyhoVGd11s/oWXXHZD/W9C/QbBiWHOe5cYJ/Ugvryhkob/6gkR1B5u6DWLOsQrokF5yMN33h3MwrKbhFYUcaH/D9hECJweVcW+zKUKBpPbI7smlaigBCFP1kUn4D4XoV4Lmr1A7Na+Cn2c3p7aVarPC7GJic4/pA0ZQKDL3/CKsTv24z44j0G4mDpT7Rp6SSYuIkb2mqufp3lqSgC/e7MkDHJ1lx5g3BmCCga0SeNaXxXvRNs05eDCewP69m58rFTS+JubhA8XrXl5Az8xovn3W9QgYR5Zp60gC7cVWigzCZTqpNBGranw3HecQ1N12hxZ/41u3ZLLz9WSIgWPI6WBQ5wXhyuFUBAD261Q==--RTxTAhlW3voVXY8u--+DWAPJUB5NgmSl6FAyhUYg==

spec/rails_helper.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# We use a dummy value since leaving it as null raises an error, and potentially
1414
# in the future we can use it in stub logic to differentiate from other external APIs.
1515
ENV['CODAVERI_URL'] ||= 'http://localhost:53896'
16-
ENV['SSID_URL'] ||= 'http://localhost:53897'
1716

1817
require 'spec_helper'
1918
require 'rspec/rails'

spec/services/course/assessment/submission/ssid_plagiarism_service_spec.rb

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
end
2121

2222
before do
23-
allow_any_instance_of(SsidAsyncApiService).to receive(:connection).and_return(connection)
23+
allow(SsidApiService).to receive(:api_url).and_return('http://localhost:53897')
24+
allow_any_instance_of(SsidApiService).to receive(:connection).and_return(connection)
2425
allow_any_instance_of(Course::Assessment::Submission::SsidZipDownloadService).to receive(:download_and_zip).
2526
and_return([File.join(Rails.root, 'spec/fixtures/course/ssid/submissions.zip')])
2627
end
@@ -38,36 +39,66 @@
3839
end
3940

4041
describe '#start_plagiarism_check' do
41-
before do
42-
stubs.post('/folders') do
43-
[Ssid::ApiStubs::CREATE_FOLDER_SUCCESS[:status],
44-
{ 'Content-Type': 'application/json' },
45-
Ssid::ApiStubs::CREATE_FOLDER_SUCCESS[:body]]
46-
end
47-
stubs.post(/folders\/.*\/submissions/) do |env|
48-
expect(env[:url].to_s).to include("/folders/#{assessment.ssid_folder_id}/submissions")
49-
[Ssid::ApiStubs::UPLOAD_ANSWERS_SUCCESS[:status],
50-
{ 'Content-Type': 'application/json' },
51-
Ssid::ApiStubs::UPLOAD_ANSWERS_SUCCESS[:body]]
42+
context 'when presigned URL matches whitelist' do
43+
before do
44+
allow(SsidApiService).to receive(:upload_whitelist).and_return(/.*/)
45+
stubs.post('/folders') do
46+
[Ssid::ApiStubs::CREATE_FOLDER_SUCCESS[:status],
47+
{ 'Content-Type': 'application/json' },
48+
Ssid::ApiStubs::CREATE_FOLDER_SUCCESS[:body]]
49+
end
50+
stubs.post(/folders\/.*\/uploads/) do |env|
51+
expect(env[:url].to_s).to include("/folders/#{assessment.ssid_folder_id}/uploads")
52+
[Ssid::ApiStubs::GET_PRESIGNED_UPLOAD_URL_SUCCESS[:status],
53+
{ 'Content-Type': 'application/json' },
54+
Ssid::ApiStubs::GET_PRESIGNED_UPLOAD_URL_SUCCESS[:body]]
55+
end
56+
stubs.post('/') do
57+
[Ssid::ApiStubs::MULTIPART_UPLOAD_SUCCESS[:status],
58+
{},
59+
Ssid::ApiStubs::MULTIPART_UPLOAD_SUCCESS[:body]]
60+
end
61+
stubs.post(/folders\/.*\/plagiarism-checks/) do |env|
62+
expect(env[:url].to_s).to include("/folders/#{assessment.ssid_folder_id}/plagiarism-checks")
63+
[Ssid::ApiStubs::SEND_PLAGIARISM_CHECK_SUCCESS[:status],
64+
{ 'Content-Type': 'application/json' },
65+
Ssid::ApiStubs::SEND_PLAGIARISM_CHECK_SUCCESS[:body]]
66+
end
67+
stubs.get(/folders\/.*\/plagiarism-checks/) do |env|
68+
expect(env[:url].to_s).to include("/folders/#{assessment.ssid_folder_id}/plagiarism-checks")
69+
[Ssid::ApiStubs::FETCH_PLAGIARISM_CHECK_SUCCESSFUL[:status],
70+
{ 'Content-Type': 'application/json' },
71+
Ssid::ApiStubs::FETCH_PLAGIARISM_CHECK_SUCCESSFUL[:body]]
72+
end
5273
end
53-
stubs.post(/folders\/.*\/plagiarism-checks/) do |env|
54-
expect(env[:url].to_s).to include("/folders/#{assessment.ssid_folder_id}/plagiarism-checks")
55-
[Ssid::ApiStubs::SEND_PLAGIARISM_CHECK_SUCCESS[:status],
56-
{ 'Content-Type': 'application/json' },
57-
Ssid::ApiStubs::SEND_PLAGIARISM_CHECK_SUCCESS[:body]]
58-
end
59-
stubs.get(/folders\/.*\/plagiarism-checks/) do |env|
60-
expect(env[:url].to_s).to include("/folders/#{assessment.ssid_folder_id}/plagiarism-checks")
61-
[Ssid::ApiStubs::FETCH_PLAGIARISM_CHECK_SUCCESSFUL[:status],
62-
{ 'Content-Type': 'application/json' },
63-
Ssid::ApiStubs::FETCH_PLAGIARISM_CHECK_SUCCESSFUL[:body]]
74+
75+
it 'completes similarity check successfully' do
76+
subject.start_plagiarism_check
77+
response = subject.fetch_plagiarism_check_result
78+
expect(response['status']).to eq('successful')
6479
end
6580
end
6681

67-
it 'completes similarity check successfully' do
68-
subject.start_plagiarism_check
69-
response = subject.fetch_plagiarism_check_result
70-
expect(response['status']).to eq('successful')
82+
context 'when presigned URL does not match whitelist' do
83+
before do
84+
allow(SsidApiService).to receive(:upload_whitelist).and_return(/^https:\/\/trusted\.s3\.amazonaws\.com/)
85+
stubs.post('/folders') do
86+
[Ssid::ApiStubs::CREATE_FOLDER_SUCCESS[:status],
87+
{ 'Content-Type': 'application/json' },
88+
Ssid::ApiStubs::CREATE_FOLDER_SUCCESS[:body]]
89+
end
90+
stubs.post(/folders\/.*\/uploads/) do
91+
[Ssid::ApiStubs::GET_PRESIGNED_UPLOAD_URL_SUCCESS[:status],
92+
{ 'Content-Type': 'application/json' },
93+
Ssid::ApiStubs::GET_PRESIGNED_UPLOAD_URL_SUCCESS[:body]]
94+
end
95+
end
96+
97+
it 'raises SsidError before uploading' do
98+
expect_any_instance_of(Course::Assessment::Submission::SsidZipDownloadService).
99+
not_to receive(:download_and_zip)
100+
expect { subject.start_plagiarism_check }.to raise_error(SsidError)
101+
end
71102
end
72103
end
73104

0 commit comments

Comments
 (0)