Skip to content
5 changes: 5 additions & 0 deletions app/lib/error/calnet_error.rb
Original file line number Diff line number Diff line change
@@ -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
83 changes: 82 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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
Expand All @@ -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'],
Expand All @@ -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.
Expand Down Expand Up @@ -147,3 +227,4 @@ def find_primary_record
uid_patron_record
end
end
# rubocop:enable Metrics/ClassLength
17 changes: 17 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion spec/calnet_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 102 additions & 4 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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',
Expand All @@ -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')
Expand All @@ -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'
Expand All @@ -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')
Expand All @@ -102,6 +129,7 @@
'berkeleyEduStuID' => 'expected student ID',
'surname' => 'expected surname',
'berkeleyEduUCPathID' => 'expected UC Path ID',
'berkeleyEduCSID' => 'expected cs id',
'uid' => 'expected UID'
}
}
Expand Down Expand Up @@ -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