Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
131 changes: 131 additions & 0 deletions app/models/concerns/calnet_authentication.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 1 addition & 44 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
15 changes: 15 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
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