Skip to content

Commit e78e784

Browse files
authored
AP-580: Add validations for CalNet user attributes (#22)
1 parent 264813f commit e78e784

6 files changed

Lines changed: 262 additions & 49 deletions

File tree

app/lib/error/calnet_error.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module Error
2+
# Raised calnet error when it returns an unexpected response, such as missing expected attributes
3+
class CalnetError < ApplicationError
4+
end
5+
end
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Handles CalNet authentication and attribute validation for User
2+
module CalnetAuthentication
3+
FRAMEWORK_ADMIN_GROUP = 'cn=edu:berkeley:org:libr:framework:LIBR-framework-admins,ou=campus groups,dc=berkeley,dc=edu'.freeze
4+
ALMA_ADMIN_GROUP = 'cn=edu:berkeley:org:libr:framework:alma-admins,ou=campus groups,dc=berkeley,dc=edu'.freeze
5+
6+
# CalNet attribute mapping derived from configuration
7+
CALNET_ATTRS = Rails.application.config.calnet_attrs.freeze
8+
9+
def self.included(base)
10+
base.extend(ClassMethods)
11+
end
12+
13+
module ClassMethods
14+
# Returns a new user object from the given "omniauth.auth" hash. That's a
15+
# hash of all data returned by the auth provider (in our case, calnet).
16+
#
17+
# @see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema OmniAuth Schema
18+
# @see https://git.lib.berkeley.edu/lap/altmedia/issues/16#note_5549 Sample Calnet Response
19+
# @see https://calnetweb.berkeley.edu/calnet-technologists/ldap-directory-service/how-ldap-organized/people-ou/people-attribute-schema CalNet LDAP
20+
def from_omniauth(auth)
21+
raise Error::InvalidAuthProviderError, auth['provider'] \
22+
if auth['provider'].to_sym != :calnet
23+
24+
new(**auth_params_from(auth))
25+
end
26+
27+
private
28+
29+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
30+
def auth_params_from(auth)
31+
auth_extra = auth['extra']
32+
verify_calnet_attributes!(auth_extra)
33+
cal_groups = auth_extra['berkeleyEduIsMemberOf'] || []
34+
35+
# NOTE: berkeleyEduCSID should be same as berkeleyEduStuID for students
36+
{
37+
affiliations: get_attribute_from_auth(auth_extra, :affiliations),
38+
cs_id: auth_extra['berkeleyEduCSID'], # Not included in CALNET_ATTRS because it's not used by any applications; Just keep it here.
39+
department_number: get_attribute_from_auth(auth_extra, :department_number),
40+
display_name: get_attribute_from_auth(auth_extra, :display_name),
41+
email: get_attribute_from_auth(auth_extra, :email),
42+
employee_id: get_attribute_from_auth(auth_extra, :employee_id),
43+
given_name: get_attribute_from_auth(auth_extra, :given_name),
44+
student_id: get_attribute_from_auth(auth_extra, :student_id),
45+
surname: get_attribute_from_auth(auth_extra, :surname),
46+
ucpath_id: get_attribute_from_auth(auth_extra, :ucpath_id),
47+
uid: auth_extra['uid'] || auth['uid'],
48+
framework_admin: cal_groups.include?(FRAMEWORK_ADMIN_GROUP),
49+
alma_admin: cal_groups.include?(ALMA_ADMIN_GROUP)
50+
}
51+
end
52+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
53+
54+
# Verifies that auth_extra contains all required CalNet attributes with exact case-sensitive names
55+
# For array attributes, at least one value in the array must be present in auth_extra
56+
# Not all users have the same attributes, so the required attributes are determined based on the user's affiliations (e.g. employee vs student)
57+
# Raise [Error::CalnetError] if any required attributes are missing
58+
def verify_calnet_attributes!(auth_extra)
59+
affiliations = affiliations_from(auth_extra)
60+
raise_missing_calnet_attribute_error(auth_extra, ['berkeleyEduAffiliations']) if affiliations.blank?
61+
62+
required_attributes = required_attributes_for(affiliations)
63+
64+
missing = required_attributes.reject do |attr|
65+
present_in_auth_extra?(auth_extra, attr)
66+
end
67+
68+
return if missing.empty?
69+
70+
raise_missing_calnet_attribute_error(auth_extra, missing)
71+
end
72+
73+
def raise_missing_calnet_attribute_error(auth_extra, missing)
74+
missing_attrs = "Expected CalNet attribute(s) not found (case-sensitive): #{missing.join(', ')}."
75+
actual_calnet_keys = auth_extra.keys.reject { |k| k.start_with?('duo') }.sort
76+
msg = "#{missing_attrs} The actual CalNet attributes: #{actual_calnet_keys.join(', ')}. The user is #{auth_extra['displayName']}"
77+
Rails.logger.error(msg)
78+
raise Error::CalnetError, msg
79+
end
80+
81+
def affiliations_from(auth_extra)
82+
Array(auth_extra['berkeleyEduAffiliations'])
83+
end
84+
85+
def employee_affiliated?(affiliations)
86+
affiliations.include?('EMPLOYEE-TYPE-STAFF') ||
87+
affiliations.include?('EMPLOYEE-TYPE-ACADEMIC')
88+
end
89+
90+
def student_affiliated?(affiliations)
91+
affiliations.include?('STUDENT-TYPE-NOT-REGISTERED') ||
92+
affiliations.include?('STUDENT-TYPE-REGISTERED')
93+
end
94+
95+
def required_attributes_for(affiliations)
96+
required_cal_attrs = CALNET_ATTRS.dup
97+
required_cal_attrs.delete(:affiliations)
98+
99+
# only employee afflication will validate employee_id and ucpath_id attributes.
100+
unless employee_affiliated?(affiliations)
101+
required_cal_attrs.delete(:employee_id)
102+
required_cal_attrs.delete(:ucpath_id)
103+
end
104+
105+
# only student registered and not-registered affiliation will validate student_id attribute.
106+
required_cal_attrs.delete(:student_id) unless student_affiliated?(affiliations)
107+
108+
required_cal_attrs.values
109+
end
110+
111+
def present_in_auth_extra?(auth_extra, attr)
112+
if attr.is_a?(Array)
113+
attr.any? { |a| auth_extra.key?(a) }
114+
else
115+
auth_extra.key?(attr)
116+
end
117+
end
118+
119+
# Gets an attribute value from auth_extra, handling both string and array attribute names as defined in CALNET_ATTRS.
120+
# For array attribute names, it tries each name in order and returns the first match.
121+
# This is to handle situations where the same attribute may have different attribute names
122+
# (e.g. berkeleyEduAlternateID vs berkeleyEduAlternateId).
123+
# If attribute is a string, returns the value for that key
124+
def get_attribute_from_auth(auth_extra, attr_key)
125+
attrs = CALNET_ATTRS[attr_key]
126+
return auth_extra[attrs] unless attrs.is_a?(Array)
127+
128+
attrs.find { |attr| auth_extra.key?(attr) }.then { |attr| attr && auth_extra[attr] }
129+
end
130+
end
131+
end

app/models/user.rb

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,50 +3,7 @@
33
# This is closely coupled to CalNet's user schema.
44
class User
55
include ActiveModel::Model
6-
7-
FRAMEWORK_ADMIN_GROUP = 'cn=edu:berkeley:org:libr:framework:LIBR-framework-admins,ou=campus groups,dc=berkeley,dc=edu'.freeze
8-
ALMA_ADMIN_GROUP = 'cn=edu:berkeley:org:libr:framework:alma-admins,ou=campus groups,dc=berkeley,dc=edu'.freeze
9-
10-
class << self
11-
# Returns a new user object from the given "omniauth.auth" hash. That's a
12-
# hash of all data returned by the auth provider (in our case, calnet).
13-
#
14-
# @see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema OmniAuth Schema
15-
# @see https://git.lib.berkeley.edu/lap/altmedia/issues/16#note_5549 Sample Calnet Response
16-
# @see https://calnetweb.berkeley.edu/calnet-technologists/ldap-directory-service/how-ldap-organized/people-ou/people-attribute-schema CalNet LDAP
17-
def from_omniauth(auth)
18-
raise Error::InvalidAuthProviderError, auth['provider'] \
19-
if auth['provider'].to_sym != :calnet
20-
21-
new(**auth_params_from(auth))
22-
end
23-
24-
private
25-
26-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
27-
def auth_params_from(auth)
28-
auth_extra = auth['extra']
29-
cal_groups = auth_extra['berkeleyEduIsMemberOf'] || []
30-
31-
# NOTE: berkeleyEduCSID should be same as berkeleyEduStuID for students
32-
{
33-
affiliations: auth_extra['berkeleyEduAffiliations'],
34-
cs_id: auth_extra['berkeleyEduCSID'],
35-
department_number: auth_extra['departmentNumber'],
36-
display_name: auth_extra['displayName'],
37-
email: auth_extra['berkeleyEduAlternateID'] || auth_extra['berkeleyEduAlternateId'],
38-
employee_id: auth_extra['employeeNumber'],
39-
given_name: auth_extra['givenName'],
40-
student_id: auth_extra['berkeleyEduStuID'],
41-
surname: auth_extra['surname'],
42-
ucpath_id: auth_extra['berkeleyEduUCPathID'],
43-
uid: auth_extra['uid'] || auth['uid'],
44-
framework_admin: cal_groups.include?(FRAMEWORK_ADMIN_GROUP),
45-
alma_admin: cal_groups.include?(ALMA_ADMIN_GROUP)
46-
}
47-
end
48-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
49-
end
6+
include CalnetAuthentication
507

518
# Affiliations per CalNet (attribute `berkeleyEduAffiliations` e.g.
529
# `EMPLOYEE-TYPE-FACULTY`, `STUDENT-TYPE-REGISTERED`).

config/application.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,21 @@ def log_active_storage_root!(active_storage_root)
111111
config.x.healthcheck_urls.whois = 'https://whois.arin.net/rest/poc/1AD-ARIN'
112112
config.x.healthcheck_urls.berkeley_service_now = 'https://berkeley.service-now.com/kb_view.do?sysparm_article=KB0011960'
113113

114+
# CalNet attribute mapping - Maps hash values to CalNet attribute name(s), shared between User model and test calnet_helper;
115+
# names need validation in User#from_omniauth and test coverage in calnet_helper_spec.
116+
# Array values indicate fallback/alternative attribute names
117+
config.calnet_attrs = {
118+
affiliations: 'berkeleyEduAffiliations',
119+
ucpath_id: 'berkeleyEduUCPathID',
120+
student_id: 'berkeleyEduStuID',
121+
email: %w[berkeleyEduAlternateID berkeleyEduAlternateId],
122+
department_number: 'departmentNumber',
123+
display_name: 'displayName',
124+
employee_id: 'employeeNumber',
125+
given_name: 'givenName',
126+
surname: 'surname'
127+
}.freeze
128+
114129
config.to_prepare do
115130
GoodJob::JobsController.class_eval do
116131
include AuthSupport

spec/calnet_helper.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,14 @@ def auth_hash_for(uid)
5454
calnet_yml_file = "spec/data/calnet/#{uid}.yml"
5555
raise IOError, "No such file: #{calnet_yml_file}" unless File.file?(calnet_yml_file)
5656

57-
YAML.load_file(calnet_yml_file)
57+
auth_hash = YAML.load_file(calnet_yml_file)
58+
59+
# Merge in default extra testing fields using attribute names from config
60+
attr_names = Rails.application.config.calnet_attrs.values.map { |v| v.is_a?(Array) ? v.first : v }.freeze
61+
default_extra_subfields = attr_names.to_h { |attr| [attr, "fake_#{attr}"] }
62+
auth_hash['extra'] = default_extra_subfields.merge(auth_hash['extra'] || {})
63+
64+
auth_hash
5865
end
5966

6067
# Logs out. Suitable for calling in an after() block.

spec/models/user_spec.rb

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,33 @@
2121
expect { User.from_omniauth(auth) }.to raise_error(Error::InvalidAuthProviderError)
2222
end
2323

24+
it 'rejects calnet when a required schema attribute is missing or renamed' do
25+
auth = {
26+
'provider' => 'calnet',
27+
'extra' => {
28+
'berkeleyEduAffiliations' => 'expected affiliation',
29+
'berkeleyEduCSID' => 'expected cs id',
30+
'berkeleyEduIsMemberOf' => [],
31+
'berkeleyEduUCPathID' => 'expected UC Path ID',
32+
'berkeleyEduAlternatid' => 'expected email', # intentionally wrong case to simulate wrong attribute
33+
'departmentNumber' => 'expected dept. number',
34+
'displayName' => 'expected display name',
35+
'employeeNumber' => 'expected employee ID',
36+
'givenName' => 'expected given name',
37+
'surname' => 'expected surname',
38+
'uid' => 'expected UID'
39+
}
40+
}
41+
42+
missing = %w[berkeleyEduAlternateID berkeleyEduAlternateId]
43+
actual = %w[berkeleyEduAffiliations berkeleyEduAlternatid berkeleyEduCSID berkeleyEduIsMemberOf berkeleyEduUCPathID departmentNumber
44+
displayName employeeNumber givenName surname uid]
45+
# rubocop:disable Layout/LineLength
46+
msg = "Expected CalNet attribute(s) not found (case-sensitive): #{missing.join(', ')}. The actual CalNet attributes: #{actual.join(', ')}. The user is expected display name"
47+
# rubocop:enable Layout/LineLength
48+
expect { User.from_omniauth(auth) }.to raise_error(Error::CalnetError, msg)
49+
end
50+
2451
it 'populates a User object' do
2552
framework_admin_ldap = 'cn=edu:berkeley:org:libr:framework:LIBR-framework-admins,ou=campus groups,dc=berkeley,dc=edu'
2653
auth = {
@@ -32,7 +59,7 @@
3259
'berkeleyEduAlternateID' => 'expected email',
3360
'employeeNumber' => 'expected employee ID',
3461
'givenName' => 'expected given name',
35-
'berkeleyEduStuID' => 'expected student ID',
62+
'berkeleyEduCSID' => 'expected cs id',
3663
'surname' => 'expected surname',
3764
'berkeleyEduUCPathID' => 'expected UC Path ID',
3865
'uid' => 'expected UID',
@@ -49,7 +76,7 @@
4976
expect(user.email).to eq('expected email')
5077
expect(user.employee_id).to eq('expected employee ID')
5178
expect(user.given_name).to eq('expected given name')
52-
expect(user.student_id).to eq('expected student ID')
79+
expect(user.student_id).to eq(nil)
5380
expect(user.surname).to eq('expected surname')
5481
expect(user.ucpath_id).to eq('expected UC Path ID')
5582
expect(user.uid).to eq('expected UID')
@@ -67,7 +94,7 @@
6794
'berkeleyEduAlternateID' => 'expected email',
6895
'employeeNumber' => 'expected employee ID',
6996
'givenName' => 'expected given name',
70-
'berkeleyEduStuID' => 'expected student ID',
97+
'berkeleyEduCSID' => 'expected cs id',
7198
'surname' => 'expected surname',
7299
'berkeleyEduUCPathID' => 'expected UC Path ID',
73100
'uid' => 'expected UID'
@@ -81,7 +108,7 @@
81108
expect(user.email).to eq('expected email')
82109
expect(user.employee_id).to eq('expected employee ID')
83110
expect(user.given_name).to eq('expected given name')
84-
expect(user.student_id).to eq('expected student ID')
111+
expect(user.student_id).to eq(nil)
85112
expect(user.surname).to eq('expected surname')
86113
expect(user.ucpath_id).to eq('expected UC Path ID')
87114
expect(user.uid).to eq('expected UID')
@@ -102,6 +129,7 @@
102129
'berkeleyEduStuID' => 'expected student ID',
103130
'surname' => 'expected surname',
104131
'berkeleyEduUCPathID' => 'expected UC Path ID',
132+
'berkeleyEduCSID' => 'expected cs id',
105133
'uid' => 'expected UID'
106134
}
107135
}
@@ -134,4 +162,74 @@
134162
end
135163
end
136164

165+
describe :verify_calnet_attributes! do
166+
it 'allows employee-affiliated users without berkeleyEduStuID' do
167+
auth_extra = {
168+
'berkeleyEduAffiliations' => ['EMPLOYEE-TYPE-ACADEMIC'],
169+
'berkeleyEduCSID' => 'cs123',
170+
'berkeleyEduIsMemberOf' => [],
171+
'berkeleyEduUCPathID' => 'ucpath456',
172+
'berkeleyEduAlternateID' => 'email@berkeley.edu',
173+
'departmentNumber' => 'dept1',
174+
'displayName' => 'Test Faculty',
175+
'employeeNumber' => 'emp789',
176+
'givenName' => 'Test',
177+
'surname' => 'Faculty',
178+
'uid' => 'faculty1'
179+
}
180+
181+
expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.not_to raise_error
182+
end
183+
184+
it 'allows student-affiliated users without employeeNumber and berkeleyEduUCPathID' do
185+
auth_extra = {
186+
'berkeleyEduAffiliations' => ['STUDENT-TYPE-REGISTERED'],
187+
'berkeleyEduCSID' => 'cs123',
188+
'berkeleyEduIsMemberOf' => [],
189+
'berkeleyEduStuID' => 'stu456',
190+
'berkeleyEduAlternateID' => 'email@berkeley.edu',
191+
'departmentNumber' => 'dept1',
192+
'displayName' => 'Test Student',
193+
'givenName' => 'Test',
194+
'surname' => 'Student',
195+
'uid' => 'student1'
196+
}
197+
198+
expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.not_to raise_error
199+
end
200+
201+
it 'rejects student-affiliated users if berkeleyEduStuID is missing' do
202+
auth_extra = {
203+
'berkeleyEduAffiliations' => ['STUDENT-TYPE-REGISTERED'],
204+
'berkeleyEduCSID' => 'cs123',
205+
'berkeleyEduIsMemberOf' => [],
206+
'berkeleyEduAlternateID' => 'email@berkeley.edu',
207+
'departmentNumber' => 'dept1',
208+
'displayName' => 'Test Student',
209+
'givenName' => 'Test',
210+
'surname' => 'Student',
211+
'uid' => 'student1'
212+
}
213+
214+
expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.to raise_error(Error::CalnetError)
215+
end
216+
217+
it 'rejects employee-affiliated users if employeeNumber is missing' do
218+
auth_extra = {
219+
'berkeleyEduAffiliations' => ['EMPLOYEE-TYPE-STAFF'],
220+
'berkeleyEduCSID' => 'cs123',
221+
'berkeleyEduIsMemberOf' => [],
222+
'berkeleyEduUCPathID' => 'ucpath456',
223+
'berkeleyEduAlternateID' => 'email@berkeley.edu',
224+
'departmentNumber' => 'dept1',
225+
'displayName' => 'Test Staff',
226+
'givenName' => 'Test',
227+
'surname' => 'Staff',
228+
'uid' => 'staff1'
229+
}
230+
231+
expect { User.from_omniauth({ 'provider' => 'calnet', 'extra' => auth_extra }) }.to raise_error(Error::CalnetError)
232+
end
233+
end
234+
137235
end

0 commit comments

Comments
 (0)