diff --git a/app/lib/error/calnet_error.rb b/app/lib/error/calnet_error.rb new file mode 100644 index 00000000..4aacc0df --- /dev/null +++ b/app/lib/error/calnet_error.rb @@ -0,0 +1,5 @@ +module Error + # Raised calnet error when it returns an unexpected response, such as missing expected attributes + class CalnetError < ApplicationError + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 227b170c..e3beca87 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,6 +7,9 @@ class User FRAMEWORK_ADMIN_GROUP = 'cn=edu:berkeley:org:libr:framework:LIBR-framework-admins,ou=campus groups,dc=berkeley,dc=edu'.freeze ALMA_ADMIN_GROUP = 'cn=edu:berkeley:org:libr:framework:alma-admins,ou=campus groups,dc=berkeley,dc=edu'.freeze + # CalNet attribute mapping derived from configuration + CALNET_ATTRS = Rails.application.config.calnet_attrs.freeze + class << self # Returns a new user object from the given "omniauth.auth" hash. That's a # hash of all data returned by the auth provider (in our case, calnet). @@ -23,29 +26,68 @@ def from_omniauth(auth) private - # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def auth_params_from(auth) auth_extra = auth['extra'] + verify_calnet_attributes!(auth_extra) cal_groups = auth_extra['berkeleyEduIsMemberOf'] || [] # NOTE: berkeleyEduCSID should be same as berkeleyEduStuID for students { - affiliations: auth_extra['berkeleyEduAffiliations'], - cs_id: auth_extra['berkeleyEduCSID'], - department_number: auth_extra['departmentNumber'], - display_name: auth_extra['displayName'], - email: auth_extra['berkeleyEduAlternateID'], - employee_id: auth_extra['employeeNumber'], - given_name: auth_extra['givenName'], - student_id: auth_extra['berkeleyEduStuID'], - surname: auth_extra['surname'], - ucpath_id: auth_extra['berkeleyEduUCPathID'], - uid: auth_extra['uid'] || auth['uid'], + affiliations: get_attribute_from_auth(auth_extra, :affiliations), + cs_id: get_attribute_from_auth(auth_extra, :cs_id), + department_number: get_attribute_from_auth(auth_extra, :department_number), + display_name: get_attribute_from_auth(auth_extra, :display_name), + email: get_attribute_from_auth(auth_extra, :email), + employee_id: get_attribute_from_auth(auth_extra, :employee_id), + given_name: get_attribute_from_auth(auth_extra, :given_name), + student_id: get_attribute_from_auth(auth_extra, :cs_id), + surname: get_attribute_from_auth(auth_extra, :surname), + ucpath_id: get_attribute_from_auth(auth_extra, :ucpath_id), + uid: get_attribute_from_auth(auth_extra, :uid) || auth['uid'], framework_admin: cal_groups.include?(FRAMEWORK_ADMIN_GROUP), alma_admin: cal_groups.include?(ALMA_ADMIN_GROUP) } end - # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Verifies that auth_extra contains all required CalNet attributes with exact case-sensitive names + # For array attributes, at least one value in the array must be present in auth_extra + # Raise [Error::CalnetError] if any required attributes are missing + def verify_calnet_attributes!(auth_extra) + required_attributes = CALNET_ATTRS.values + + missing = required_attributes.reject do |attr| + if attr.is_a?(Array) + attr.any? { |a| auth_extra.key?(a) } + else + auth_extra.key?(attr) + end + end + + return if missing.empty? + + raise_missing_calnet_attribute_error(auth_extra, missing) + end + + def raise_missing_calnet_attribute_error(auth_extra, missing) + missing_attrs = "Expected Calnet attribute(s) not found (case-sensitive): #{missing.join(', ')}." + actual_calnet_keys = auth_extra.keys.reject { |k| k.start_with?('duo') }.sort + msg = "#{missing_attrs} The actual CalNet attributes: #{actual_calnet_keys.join(', ')}. The user is #{auth_extra['displayName']}" + Rails.logger.error(msg) + raise Error::CalnetError, msg + end + + # Gets an attribute value from auth_extra, handling both string and array attribute names + # If attribute is an array, tries each key in order and returns the first match + # If attribute is a string, returns the value for that key + def get_attribute_from_auth(auth_extra, attr_key) + attrs = CALNET_ATTRS[attr_key] + return auth_extra[attrs] unless attrs.is_a?(Array) + + attrs.find { |attr| auth_extra.key?(attr) }.then { |attr| attr && auth_extra[attr] } + end + end # Affiliations per CalNet (attribute `berkeleyEduAffiliations` e.g. diff --git a/config/application.rb b/config/application.rb index 13d2b032..2f5ffbaa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -111,6 +111,22 @@ def log_active_storage_root!(active_storage_root) config.x.healthcheck_urls.whois = 'https://whois.arin.net/rest/poc/1AD-ARIN' config.x.healthcheck_urls.berkeley_service_now = 'https://berkeley.service-now.com/kb_view.do?sysparm_article=KB0011960' + # CalNet attribute mapping - shared between User model and test calnet_helper + # Maps hash values to CalNet attribute name(s) + # Array values indicate fallback/alternative attribute names + config.calnet_attrs = { + affiliations: 'berkeleyEduAffiliations', + cs_id: %w[berkeleyEduStuID berkeleyEduCSID], + ucpath_id: 'berkeleyEduUCPathID', + email: %w[berkeleyEduAlternateID berkeleyEduAlternateId], + department_number: 'departmentNumber', + display_name: 'displayName', + employee_id: 'employeeNumber', + given_name: 'givenName', + surname: 'surname', + uid: 'uid' + }.freeze + config.to_prepare do GoodJob::JobsController.class_eval do include AuthSupport diff --git a/spec/calnet_helper.rb b/spec/calnet_helper.rb index 636497af..cc46b432 100644 --- a/spec/calnet_helper.rb +++ b/spec/calnet_helper.rb @@ -54,7 +54,14 @@ def auth_hash_for(uid) calnet_yml_file = "spec/data/calnet/#{uid}.yml" raise IOError, "No such file: #{calnet_yml_file}" unless File.file?(calnet_yml_file) - YAML.load_file(calnet_yml_file) + auth_hash = YAML.load_file(calnet_yml_file) + + # Merge in default extra testing fields using attribute names from config + attr_names = Rails.application.config.calnet_attrs.values.map { |v| v.is_a?(Array) ? v.first : v }.freeze + default_extra_subfields = attr_names.to_h { |attr| [attr, "fake_#{attr}"] } + auth_hash['extra'] = default_extra_subfields.merge(auth_hash['extra'] || {}) + + auth_hash end # Logs out. Suitable for calling in an after() block. diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 058c343d..2c1107d6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -21,6 +21,33 @@ expect { User.from_omniauth(auth) }.to raise_error(Error::InvalidAuthProviderError) end + it 'rejects calnet when a required schema attribute is missing or renamed' do + auth = { + 'provider' => 'calnet', + 'extra' => { + 'berkeleyEduAffiliations' => 'expected affiliation', + 'berkeleyEduCSID' => 'expected cs id', + 'berkeleyEduIsMemberOf' => [], + 'berkeleyEduUCPathID' => 'expected UC Path ID', + 'berkeleyEduAlternatid' => 'expected email', # intentionally wrong case to simulate wrong attribute + 'departmentNumber' => 'expected dept. number', + 'displayName' => 'expected display name', + 'employeeNumber' => 'expected employee ID', + 'givenName' => 'expected given name', + 'surname' => 'expected surname', + 'uid' => 'expected UID' + } + } + + missing = %w[berkeleyEduAlternateID berkeleyEduAlternateId] + actual = %w[berkeleyEduAffiliations berkeleyEduAlternatid berkeleyEduCSID berkeleyEduIsMemberOf berkeleyEduUCPathID departmentNumber + displayName employeeNumber givenName surname uid] + # rubocop:disable Layout/LineLength + msg = "Expected Calnet attribute(s) not found (case-sensitive): #{missing.join(', ')}. The actual CalNet attributes: #{actual.join(', ')}. The user is expected display name" + # rubocop:enable Layout/LineLength + expect { User.from_omniauth(auth) }.to raise_error(Error::CalnetError, msg) + end + it 'populates a User object' do framework_admin_ldap = 'cn=edu:berkeley:org:libr:framework:LIBR-framework-admins,ou=campus groups,dc=berkeley,dc=edu' auth = { @@ -32,7 +59,7 @@ 'berkeleyEduAlternateID' => 'expected email', 'employeeNumber' => 'expected employee ID', 'givenName' => 'expected given name', - 'berkeleyEduStuID' => 'expected student ID', + 'berkeleyEduCSID' => 'expected cs id', 'surname' => 'expected surname', 'berkeleyEduUCPathID' => 'expected UC Path ID', 'uid' => 'expected UID', @@ -49,7 +76,7 @@ expect(user.email).to eq('expected email') expect(user.employee_id).to eq('expected employee ID') expect(user.given_name).to eq('expected given name') - expect(user.student_id).to eq('expected student ID') + expect(user.student_id).to eq('expected cs id') expect(user.surname).to eq('expected surname') expect(user.ucpath_id).to eq('expected UC Path ID') expect(user.uid).to eq('expected UID') @@ -67,7 +94,7 @@ 'berkeleyEduAlternateID' => 'expected email', 'employeeNumber' => 'expected employee ID', 'givenName' => 'expected given name', - 'berkeleyEduStuID' => 'expected student ID', + 'berkeleyEduCSID' => 'expected cs id', 'surname' => 'expected surname', 'berkeleyEduUCPathID' => 'expected UC Path ID', 'uid' => 'expected UID' @@ -81,7 +108,7 @@ expect(user.email).to eq('expected email') expect(user.employee_id).to eq('expected employee ID') expect(user.given_name).to eq('expected given name') - expect(user.student_id).to eq('expected student ID') + expect(user.student_id).to eq('expected cs id') expect(user.surname).to eq('expected surname') expect(user.ucpath_id).to eq('expected UC Path ID') expect(user.uid).to eq('expected UID')