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/concerns/calnet_authentication.rb b/app/models/concerns/calnet_authentication.rb new file mode 100644 index 00000000..dcf1f498 --- /dev/null +++ b/app/models/concerns/calnet_authentication.rb @@ -0,0 +1,131 @@ +# Handles CalNet authentication and attribute validation for User +module CalnetAuthentication + 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 + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # 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). + # + # @see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema OmniAuth Schema + # @see https://git.lib.berkeley.edu/lap/altmedia/issues/16#note_5549 Sample Calnet Response + # @see https://calnetweb.berkeley.edu/calnet-technologists/ldap-directory-service/how-ldap-organized/people-ou/people-attribute-schema CalNet LDAP + def from_omniauth(auth) + raise Error::InvalidAuthProviderError, auth['provider'] \ + if auth['provider'].to_sym != :calnet + + new(**auth_params_from(auth)) + end + + private + + # 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: get_attribute_from_auth(auth_extra, :affiliations), + cs_id: auth_extra['berkeleyEduCSID'], # Not included in CALNET_ATTRS because it's not used by any applications; Just keep it here. + 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, :student_id), + surname: get_attribute_from_auth(auth_extra, :surname), + ucpath_id: get_attribute_from_auth(auth_extra, :ucpath_id), + uid: 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/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 + # Not all users have the same attributes, so the required attributes are determined based on the user's affiliations (e.g. employee vs student) + # 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 as defined in CALNET_ATTRS. + # For array attribute names, it tries each name in order and returns the first match. + # This is to handle situations where the same attribute may have different attribute names + # (e.g. berkeleyEduAlternateID vs berkeleyEduAlternateId). + # 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 +end diff --git a/app/models/user.rb b/app/models/user.rb index f1943ccb..19e0100b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,50 +3,7 @@ # This is closely coupled to CalNet's user schema. 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 - - 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). - # - # @see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema OmniAuth Schema - # @see https://git.lib.berkeley.edu/lap/altmedia/issues/16#note_5549 Sample Calnet Response - # @see https://calnetweb.berkeley.edu/calnet-technologists/ldap-directory-service/how-ldap-organized/people-ou/people-attribute-schema CalNet LDAP - def from_omniauth(auth) - raise Error::InvalidAuthProviderError, auth['provider'] \ - if auth['provider'].to_sym != :calnet - - new(**auth_params_from(auth)) - end - - private - - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - def auth_params_from(auth) - auth_extra = 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'] || 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'], - framework_admin: cal_groups.include?(FRAMEWORK_ADMIN_GROUP), - alma_admin: cal_groups.include?(ALMA_ADMIN_GROUP) - } - end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - end + include CalnetAuthentication # Affiliations per CalNet (attribute `berkeleyEduAffiliations` e.g. # `EMPLOYEE-TYPE-FACULTY`, `STUDENT-TYPE-REGISTERED`). diff --git a/config/application.rb b/config/application.rb index 13d2b032..59298f3f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -111,6 +111,21 @@ 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 - Maps hash values to CalNet attribute name(s), shared between User model and test calnet_helper; + # names need validation in User#from_omniauth and test coverage in calnet_helper_spec. + # Array values indicate fallback/alternative attribute names + config.calnet_attrs = { + affiliations: 'berkeleyEduAffiliations', + ucpath_id: 'berkeleyEduUCPathID', + student_id: 'berkeleyEduStuID', + email: %w[berkeleyEduAlternateID berkeleyEduAlternateId], + department_number: 'departmentNumber', + display_name: 'displayName', + employee_id: 'employeeNumber', + given_name: 'givenName', + surname: 'surname' + }.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..86f6ed98 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