Skip to content
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

E2421 Reimplement impersonating users #88

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
37 changes: 37 additions & 0 deletions app/controllers/api/v1/impersonate_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Api::V1::ImpersonateController < ApplicationController

# Fetches users to impersonate whose name match the passed parameter
def get_users_list
users = current_user.get_available_users(params[:user_name])
render json: { message: "Successfully Fetched User List!", userList:users, success:true }, status: :ok
end

def user_is_impersonatable?
impersonate_user = User.find_by(id: params[:impersonate_id])
if impersonate_user
return current_user.can_impersonate? impersonate_user
end
false
end

# Impersonates a new user and returns new jwt token
def impersonate
unless params[:impersonate_id].present?
render json: { error: "impersonate_id is required", success:false }, status: :unprocessable_entity
return
end

if user_is_impersonatable?
impersonate_user = User.find_by(id: params[:impersonate_id])

payload = { id: impersonate_user.id, name: impersonate_user.name, full_name: impersonate_user.full_name, role: impersonate_user.role.name,
institution_id: impersonate_user.institution.id, impersonated:true, original_user: current_user }
impersonate_user_token = JsonWebToken.encode(payload, 24.hours.from_now)

render json: { message: "Successfully Impersonated #{impersonate_user.name}!", token:impersonate_user_token, success:true }, status: :ok

else
render json: { error: "You do not have permission to impersonate this user", success:false }, status: :forbidden
end
end
end
3 changes: 3 additions & 0 deletions app/models/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ class Assignment < ApplicationRecord
include MetricHelper
has_many :invitations
has_many :questionnaires
belongs_to :course
has_many :participants, dependent: :destroy
has_many :users, through: :participants, inverse_of: :assignment

def review_questionnaire_id
Questionnaire.find_by_assignment_id id
Expand Down
1 change: 1 addition & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Course < ApplicationRecord
validates :directory_path, presence: true
has_many :ta_mappings, dependent: :destroy
has_many :tas, through: :ta_mappings
has_many :assignments, dependent: :destroy

# Returns the submission directory for the course
def path
Expand Down
4 changes: 4 additions & 0 deletions app/models/instructor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ def managed_users
User.where(parent_id: id).to_a
end

def self.list_all(object_type, user_id)
object_type.where('instructor_id = ? AND private = 0', user_id)
end


end
2 changes: 2 additions & 0 deletions app/models/ta.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Ta < User
has_many :ta_mappings, dependent: :destroy

# Get all users whose parent is the TA
# @return [Array<User>] all users that belongs to courses that is mapped to the TA
def managed_users
Expand Down
9 changes: 7 additions & 2 deletions app/models/ta_mapping.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ class TaMapping < ApplicationRecord

#Returns course ids of the TA
def self.get_course_ids(user_id)
TaMapping.find_by(ta_id: user_id).course_id
ta_mapping = TaMapping.find_by(user_id: user_id)
ta_mapping&.course_id
end

#Returns courses of the TA
def self.get_courses(user_id)
Course.where('id = ?', get_course_ids(user_id))
course_ids = get_course_ids(user_id)

return Course.none unless course_ids # Return Course.none if course_ids is nil

Course.where(id: course_ids)
end
end
101 changes: 101 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class User < ApplicationRecord
belongs_to :parent, class_name: 'User', optional: true
has_many :users, foreign_key: 'parent_id', dependent: :nullify
has_many :invitations
has_many :assignments, through: :participants

scope :students, -> { where role_id: Role::STUDENT }
scope :tas, -> { where role_id: Role::TEACHING_ASSISTANT }
Expand Down Expand Up @@ -83,6 +84,106 @@ def self.from_params(params)
user
end

# Fetches available users whose full names match the provided name prefix (case-insensitive).
# Returns a limited list of users (up to 10) who have roles similar or subordinate to the current user's role.
def get_available_users(name)
lesser_roles = role.subordinate_roles_and_self
all_users = User.where('full_name LIKE ?', "%#{name}%").limit(20)
visible_users = all_users.select { |user| lesser_roles.include? user.role }
visible_users[0, 10] # the first 10
end

# Check if the user can impersonate another user
def can_impersonate?(user)
return true if role.super_administrator?
return true if instructor_for?(user)
# Skip below check if user's role is "Instructor"
return false if instructor?
return true if teaching_assistant_for?(user)
# Skip recursively_parent_of check if user's role is "Teaching Assistant"
return false if teaching_assistant?
return true if recursively_parent_of(user.role)
false
end

# Check if the current user is an instructor and has any relationship with the given user (student or TA)
def instructor_for?(user)
return false unless instructor?
return true if instructor_for_student?(user)
return true if instructor_for_ta?(user)
end

# Helper method to check if there are any courses where a student is enrolled in assignments
def courses_where_student_participates(courses, student)
courses.any? do |course|
course.assignments.any? do |assignment|
assignment.participants.map(&:user_id).include?(student.id)
end
end
end

# Check if the instructor has any relationship with the given student
def instructor_for_student?(student)
return false unless student.role.name == 'Student' # Ensure the role is 'Student'

instructor = Instructor.find(id)

# Check if the instructor has any courses where the student is enrolled in an assignment
return courses_where_student_participates(Instructor.list_all(Course, instructor),student)
end

# Check if the instructor has common courses with the given teaching assistant
def instructor_for_ta?(ta)
return false unless ta.role.name == 'Teaching Assistant' # Ensure the role is 'Teaching Assistant'

instructor = Instructor.find(id)

# Get all courses taught by the instructor
instructor_courses = Instructor.list_all(Course, instructor)

# Get all courses associated with the TA
ta_courses = TaMapping.get_courses(ta)

# Convert lists to sets for efficient intersection
instructor_course_set = instructor_courses.to_set
ta_course_set = ta_courses.to_set

# Check for common courses using set intersection
has_common_course = !(instructor_course_set & ta_course_set).empty?

return has_common_course
end

# Check if the user is a teaching assistant for the student's course
def teaching_assistant_for?(student)
return false unless teaching_assistant?
return false unless student.role.name == 'Student'

# We have to use the Ta object instead of User object
# because single table inheritance is not currently functioning
ta = Ta.find(id)

# Check if the TA has any courses where the student is enrolled in an assignment
return courses_where_student_participates(TaMapping.get_courses(ta),student)

false
end

# Check if the user is a teaching assistant
def teaching_assistant?
true if role.ta?
end

# Recursively check if parent child relationship exists
def recursively_parent_of(user_role)
p = user_role.parent
return false if p.nil?
return true if p == self.role
return false if p.super_administrator?
recursively_parent_of(p)
end


# This will override the default as_json method in the ApplicationRecord class and specify
# that only the id, name, and email attributes should be included when a User object is serialized.
def as_json(options = {})
Expand Down
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
get :processed, action: :processed_requests
end
end

resources :impersonate do
get ':user_name', on: :collection, to: 'impersonate#get_users_list'
post '', on: :collection, to: 'impersonate#impersonate'
end

end
end
end
60 changes: 60 additions & 0 deletions spec/requests/api/v1/impersonate_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
require 'swagger_helper'
require 'rails_helper'

describe 'Impersonate API', type: :request do
let(:role) { create(:role, id: 1, name: 'Super-Administrator') }
let(:institution) { create(:institution, id: 100, name: 'NCSU') }
let(:user) {create(:user, id: 1, name: "admin", full_name: "admin", email: "admin@gmail.com", password_digest: "admin", role_id: role.id, institution_id: institution.id) }
let(:user_name) { user.name }
let(:auth_token) {generate_auth_token(user)}

path '/api/v1/impersonate' do

get 'Retrieves a list of users' do
tags 'Impersonate'
produces 'application/json'
parameter name: :user_name, in: :path, type: :string
parameter name: 'Authorization', in: :header, type: :string
parameter name: 'Content-Type', in: :header, type: :string
let('Authorization') { "Bearer #{auth_token}" }
let('Content-Type') { 'application/json' }

response '200', 'user list retrieved' do
run_test! do |response|
data = JSON.parse(response.body)
expect(data['userList']).not_to be_empty
end
end
end
end

path '/api/v1/impersonate' do

post 'Impersonates a user' do
tags 'Impersonate'
consumes 'application/json'
parameter name: :impersonate_id, in: :query, type: :integer

response '200', 'successfully impersonated' do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:impersonate_id) { other_user.id }

run_test! do |response|
data = JSON.parse(response.body)
expect(data['token']).not_to be_nil
end
end

response '422', 'unprocessable entity' do
let(:impersonate_id) { '' }

run_test! do
expect(response).to have_http_status(:unprocessable_entity)
end
end

end
end

end
51 changes: 51 additions & 0 deletions spec/requests/api/v1/impersonate_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require 'swagger_helper'
require 'rails_helper'

RSpec.describe 'Impersonate API', type: :request do
let(:role) { create(:role, id: 1, name: 'Super-Administrator') }
let(:institution) { create(:institution, id: 100, name: 'NCSU') }
let(:user) {create(:user, id: 1, name: "admin", full_name: "admin", email: "admin@gmail.com", password_digest: "admin", role_id: role.id, institution_id: institution.id) }
let(:user_name) { user.name }


path '/api/v1/impersonate' do

get('list of impersonatable users') do
tags 'Impersonate'
produces 'application/json'
parameter name: :user_name, in: :path, type: :string

response('200', 'user list retrieved') do
after do |example|
example.metadata[:response][:content] = {
'application/json' => {
example: JSON.parse(response.body, symbolize_names: true)
}
}
end
run_test!
end
end

post 'Impersonates a user' do
tags 'Impersonate'
consumes 'application/json'
parameter name: :impersonate_id, in: :query, type: :string

response('200', 'successfully impersonated') do
run_test! do |response|
data = JSON.parse(response.body)
expect(data['token']).not_to be_nil
end
end

response '422', 'unprocessable entity' do
run_test! do
expect(response).to have_http_status(:unprocessable_entity)
end
end

end
end

end