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 f1943ccb..e566a341 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,12 +1,16 @@ # Represents a user in our system # # This is closely coupled to CalNet's user schema. +# rubocop:disable Metrics/ClassLength class User include ActiveModel::Model 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). @@ -26,6 +30,7 @@ def from_omniauth(auth) # 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 @@ -34,7 +39,7 @@ def auth_params_from(auth) cs_id: auth_extra['berkeleyEduCSID'], department_number: auth_extra['departmentNumber'], display_name: auth_extra['displayName'], - email: auth_extra['berkeleyEduAlternateID'] || auth_extra['berkeleyEduAlternateId'], + email: get_attribute_from_auth(auth_extra, :email), employee_id: auth_extra['employeeNumber'], given_name: auth_extra['givenName'], student_id: auth_extra['berkeleyEduStuID'], @@ -46,6 +51,81 @@ def auth_params_from(auth) } end # 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) + affiliations = affiliations_from(auth_extra) + raise_missing_calnet_attribute_error(auth_extra, ['berkeleyEduAffiliations']) if affiliations.blank? + + required_attributes = required_attributes_for(affiliations) + + missing = required_attributes.reject do |attr| + present_in_auth_extra?(auth_extra, attr) + 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 + + def affiliations_from(auth_extra) + Array(auth_extra['berkeleyEduAffiliations']) + end + + def employee_affiliated?(affiliations) + affiliations.include?('EMPLOYEE-TYPE-STAFF') || + affiliations.include?('EMPLOYEE-TYPE-ACADEMIC') + end + + def student_affiliated?(affiliations) + affiliations.include?('STUDENT-TYPE-NOT-REGISTERED') || + affiliations.include?('STUDENT-TYPE-REGISTERED') + end + + def required_attributes_for(affiliations) + required_cal_attrs = CALNET_ATTRS.dup + required_cal_attrs.delete(:affiliations) + + # only employee afflication will validate employee_id and ucpath_id attributes. + unless employee_affiliated?(affiliations) + required_cal_attrs.delete(:employee_id) + required_cal_attrs.delete(:ucpath_id) + end + + # only student registered and not-registered affiliation will validate student_id attribute. + required_cal_attrs.delete(:student_id) unless student_affiliated?(affiliations) + + required_cal_attrs.values + end + + def present_in_auth_extra?(auth_extra, attr) + if attr.is_a?(Array) + attr.any? { |a| auth_extra.key?(a) } + else + auth_extra.key?(attr) + end + 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. @@ -147,3 +227,4 @@ def find_primary_record uid_patron_record end end +# rubocop:enable Metrics/ClassLength diff --git a/config/application.rb b/config/application.rb index 13d2b032..1cb6ba74 100644 --- a/config/application.rb +++ b/config/application.rb @@ -111,6 +111,23 @@ 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: 'berkeleyEduCSID', + ucpath_id: 'berkeleyEduUCPathID', + student_id: 'berkeleyEduStuID', + 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 86804ee1..0a87bfe7 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 eeb8c416..d55eb6ec 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(nil) 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(nil) expect(user.surname).to eq('expected surname') expect(user.ucpath_id).to eq('expected UC Path ID') expect(user.uid).to eq('expected UID') @@ -102,6 +129,7 @@ 'berkeleyEduStuID' => 'expected student ID', 'surname' => 'expected surname', 'berkeleyEduUCPathID' => 'expected UC Path ID', + 'berkeleyEduCSID' => 'expected cs id', 'uid' => 'expected UID' } } @@ -134,4 +162,74 @@ end end + describe :verify_calnet_attributes! do + it 'allows employee-affiliated users without berkeleyEduStuID' do + auth_extra = { + 'berkeleyEduAffiliations' => ['EMPLOYEE-TYPE-ACADEMIC'], + 'berkeleyEduCSID' => 'cs123', + 'berkeleyEduIsMemberOf' => [], + 'berkeleyEduUCPathID' => 'ucpath456', + 'berkeleyEduAlternateID' => 'email@berkeley.edu', + 'departmentNumber' => 'dept1', + 'displayName' => 'Test Faculty', + 'employeeNumber' => 'emp789', + 'givenName' => 'Test', + 'surname' => 'Faculty', + 'uid' => 'faculty1' + } + + expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.not_to raise_error + end + + it 'allows student-affiliated users without employeeNumber and berkeleyEduUCPathID' do + auth_extra = { + 'berkeleyEduAffiliations' => ['STUDENT-TYPE-REGISTERED'], + 'berkeleyEduCSID' => 'cs123', + 'berkeleyEduIsMemberOf' => [], + 'berkeleyEduStuID' => 'stu456', + 'berkeleyEduAlternateID' => 'email@berkeley.edu', + 'departmentNumber' => 'dept1', + 'displayName' => 'Test Student', + 'givenName' => 'Test', + 'surname' => 'Student', + 'uid' => 'student1' + } + + expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.not_to raise_error + end + + it 'rejects student-affiliated users if berkeleyEduStuID is missing' do + auth_extra = { + 'berkeleyEduAffiliations' => ['STUDENT-TYPE-REGISTERED'], + 'berkeleyEduCSID' => 'cs123', + 'berkeleyEduIsMemberOf' => [], + 'berkeleyEduAlternateID' => 'email@berkeley.edu', + 'departmentNumber' => 'dept1', + 'displayName' => 'Test Student', + 'givenName' => 'Test', + 'surname' => 'Student', + 'uid' => 'student1' + } + + expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.to raise_error(Error::CalnetError) + end + + it 'rejects employee-affiliated users if employeeNumber is missing' do + auth_extra = { + 'berkeleyEduAffiliations' => ['EMPLOYEE-TYPE-STAFF'], + 'berkeleyEduCSID' => 'cs123', + 'berkeleyEduIsMemberOf' => [], + 'berkeleyEduUCPathID' => 'ucpath456', + 'berkeleyEduAlternateID' => 'email@berkeley.edu', + 'departmentNumber' => 'dept1', + 'displayName' => 'Test Staff', + 'givenName' => 'Test', + 'surname' => 'Staff', + 'uid' => 'staff1' + } + + expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.to raise_error(Error::CalnetError) + end + end + end