-
Notifications
You must be signed in to change notification settings - Fork 0
AP-580: Add validations for CalNet user attributes #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 18 commits
258bf3e
a0a4788
ddc5ca3
63a17d0
e44ecd2
6f1b764
9cefd5b
d66e649
b06f0c8
0a0c50a
5713176
b432166
3b513b1
b0374ed
42d670a
14d901f
1af9ae4
b751699
7000d32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 keept 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(', ')}." | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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 | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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" | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
when the other fix is applied, this will need to be as well.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for catching the typos! |
||||||
| # 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 | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minor typo.