CSC/ECE 517 Fall 2024 - E2487. Reimplement authorization helper.rb

From Expertiza_Wiki
Jump to navigation Jump to search

Introduction

Expertiza currently uses session-based authentication in its AuthorizationHelper module. However, the new back-end reimplementation adopts JSON Web Token (JWT) for authentication. This transition is necessary to make the system more scalable, stateless, and secure. JWT-based authentication will replace session-based methods, requiring updates to the AuthorizationHelper module and associated methods.

Background

The AuthorizationHelper module in Expertiza plays a critical role in managing user permissions and access control within the system. It provides methods to verify a user’s privileges based on their assigned roles, such as Super-Admin, Admin, Instructor, TA, or Student. These methods check if the user is authorized to perform actions like submitting assignments, reviewing work, or participating in quizzes. Additionally, it determines whether a user is involved in specific assignments, either as a participant, instructor, or through TA mappings.

Problem Statement

Expertiza currently uses session-based authentication in its [AuthorizationHelper] module. The [reimplementation-back-end] however, uses JWT (JSON Web Token) based authentication. This requires a reimplementation of an authorization concern module to accommodate JWT-based authentication

Concerns

A concern is a module that you extract to split the implementation of a class or module in coherent chunks, instead of having one big class body. Our new authorization module will be implemented as a concern which will work in tandem with the already-implemented [JWT Concern]

The following points need to be taken care of as per the requirement

  • Role Management :
    • Enable the system to process, verify, and decode roles from JWT Tokens.
    • Use token role data to authenticate and authorize user actions.
  • Privilege Verification :
    • Update methods to validate user privileges using JWT claims.
  • Testing and Documentation :
    • Write comprehensive unit tests for all JWT-related methods.
    • Include clear documentation and comments to aid future developers.

Implemented Changes

JWT Token Overview

A JSON Web Token (JWT) is a compact, URL-safe token format commonly used for securely transmitting information between parties. It is widely used in web applications for authentication and authorization, as it allows information to be verified and trusted. In the context of our reimplemented AuthorizationHelper module, JWT tokens will replace session-based authentication, enabling stateless and more secure access control.

A JWT Token consists of three main parts:

  • Header: Contains metadata about the token, such as the type (JWT) and the hashing algorithm used (e.g., HS256 or RS256).
  • Payload: Includes claims, which are statements about an entity (typically, the user) and additional data like the user’s role and permissions.
  • Signature: A cryptographic signature generated by encoding the header and payload with a secret key or private key, ensuring data integrity.

These three sections are separated by dots (.), forming a token that looks like this: <Header>.<Payload>.<Signature>

Overall Flow

Methods Implemented

  • has_required_role?(required_role):
 def has_required_role?(required_role)
    required_role = Role.find_by_name(required_role) if required_role.is_a?(String)
    current_user&.role&.all_privileges_of?(required_role) || false
 end

This function checks if the current user has the required role or higher privileges. The role name is passed down as a parameter in required_role which is the minimum required role level for an action

For example:

has_required_role?('Administrator') # Checks if user is an admin or higher
has_required_role?(Role::INSTRUCTOR) # Checks if user is an instructor or higher


For exact role requirements, we also have:

  • is_role?(required_role):
 def is_role?(required_role)
    required_role = required_role.name if required_role.is_a?(Role)
    current_user&.role&.name == required_role
 end

which checks if the user has exactly the specified role. For example:

is_role?('Student') # True only if user is exactly a student 



Finally, we have authorization methods which work with the ApplicationController as well as individual resource controllers:


  # Authorize all actions
  def authorize
    unless all_actions_allowed?
      render json: { 
        error: "You are not authorized to #{params[:action]} this #{params[:controller]}"
      }, status: :forbidden
    end
  end

  # Check if all actions are allowed
  def all_actions_allowed?
    return true if has_required_role?('Administrator')
    action_allowed?
  end

  # Base action_allowed? - allows everything by default
  # Controllers should override this method to implement their authorization logic
  def action_allowed?
    true
  end


The individual resource controllers can then override the method 'action_allowed?' to manage access. For example:

  def action_allowed?
    has_required_role?('Instructor')
  end

Existing File Updates

This file was updated to include the new authorization module

 class ApplicationController < ActionController::API
   include Authorization
   include JwtToken
  
   before_action :authorize
 end


This ensures that the ['has_required_role?'] method is available globally along with ['is_role?']. These methods can then be used to override 'action_allowed?' to override the base implementation and provide more granular control



To test the functionality, based on the old Expertiza, the method was also overridden in the courses_controller.rb file:

 class Api::V1::CoursesController < ApplicationController
   before_action :set_course, only: %i[ show update destroy add_ta view_tas remove_ta copy ]
   rescue_from ActiveRecord::RecordNotFound, with: :course_not_found
   rescue_from ActionController::ParameterMissing, with: :parameter_missing

   def action_allowed?
     has_required_role?('Instructor')
   end

   ...

Outcomes

1. Modular Authentication and Authorization System

  • Separate JwtToken and Authorization concerns with distinct responsibilities
  • Integration through ApplicationController middleware
  • Flexible role-based access control per controller
  • Clean separation between authentication and authorization components
  • Independent scaling and maintenance capability

2. Role-Based Access Control (RBAC)

  • Hierarchical role system with administrator override
  • Controller-specific role requirements through action-specific checks
  • Granular permission control at the action level
  • Fine-grained access control while maintaining system flexibility
  • Custom role requirements for different resources

3. Secure Authentication Flow

  • JWT-based stateless authentication
  • Token verification precedes protected actions
  • Automatic current user loading from verified tokens
  • Protection against unauthorized access and token tampering
  • Maintains stateless architecture for scalability

4. Standardized Error Handling

  • Consistent HTTP status codes (401 for authentication, 403 for authorization)
  • Clear error messages for debugging
  • Uniform error response format across the application
  • Improved debugging experience
  • Standardized client-side error handling

4. Developer-Friendly Design

  • Rails conventions and idioms throughout
  • Clear, purpose-indicating method names
  • Modular design with extension points
  • Reduced learning curve for new developers
  • Minimal configuration required for new controllers

Design Principles

The redesign of the `AuthorizationHelper` module for JWT-based authentication will follow well-established design principles to ensure the solution is secure, maintainable, and scalable. Below are the key principles to be applied and how they will shape this redesign:

1. Single Responsibility Principle (SRP)

  • Definition: Each module or class should have one clear responsibility.
  • Implementation:
    • JwtToken concern handles only authentication
    • Authorization concern manages only authorization rules
  • Benefit: Changes to authentication logic don't affect authorization rules and vice versa

2. Open / Closed Principle

  • Definition: Software entities should be open for extension but closed for modification
  • Implementation:
 # Base authorization in Authorization concern
 def action_allowed?
   true  # Base implementation
 end

 # Extended in CoursesController without modifying base
 def action_allowed?
   has_required_role?('Instructor')
 end
  • Benefit: New controllers can implement their own authorization rules without changing existing code

3. Inheritance and Polymorphism

  • Definition: Ability to present the same interface for different underlying forms (implementations)
  • Implementation:
    • All controllers inherit from ApplicationController
    • Each can override action_allowed? with its implementation
    • System treats all action_allowed? calls polymorphically
  • Benefit: Consistent interface while allowing specialized behavior

4. Composition

  • Implementation:
 class ApplicationController < ActionController::API
   include Authorization  # Composition through modules
   include JwtToken
 end
  • Benefit: More flexible than deep inheritance hierarchies, allows mixing in different behaviors

5. Don't Repeat Yourself (DRY)

  • Definition: Every piece of knowledge should have a single, unambiguous representation
  • Implementation:
    • Common authentication logic in JwtToken
    • Shared authorization behavior in Authorization
    • Base controller provides default implementations
  • Benefit: Reduces code duplication and maintenance overhead

Test Plan

Objectives

  • Validate Individual Methods: Ensure each method in the Authorization concern performs as expected under various scenarios.
  • Simulate Real-World Scenarios: Test the integration of the Authorization concern with controllers and the overall application workflow.
  • Ensure Security Compliance: Verify that the module effectively handles unauthorized access, token tampering, and other security vulnerabilities.
  • Facilitate Future Development: Provide clear and maintainable tests to aid ongoing development and debugging.

Unit Tests

Definition: Unit tests verify that individual methods work correctly in isolation under different scenarios.

Tests for has_required_role?

Checks Privileges When Role is a String:

  • Input: required_role set to 'Administrator' as a string.
  • Expected Output: Returns true if the user has privileges for the Administrator role or higher.
  • Validation: Ensure the method identifies the role from the string and checks privileges correctly.
 
context 'when required_role is a string' do
  let(:admin_role) { instance_double('Role') }

  before do
    allow(Role).to receive(:find_by_name).with('Administrator').and_return(admin_role)
  end

  it 'finds the role and checks privileges' do
    expect(role).to receive(:all_privileges_of?).with(admin_role).and_return(true)
    expect(controller.has_required_role?('Administrator')).to be true
  end
end

Directly Checks Privileges for Role Object:

  • Input: required_role set to an instance of Role.
  • Expected Output: Returns false if the user does not meet the required role privileges.
  • Validation: Ensure the method directly uses the role object without additional lookups.
 
  context 'when required_role is a Role object' do
	let(:instructor_role) { instance_double('Role') }

	it 'directly checks privileges' do
	  expect(role).to receive(:all_privileges_of?).with(instructor_role).and_return(false)
	  expect(controller.has_required_role?(instructor_role)).to be false
	end
  end

Returns False When User is Not Logged In:

  • Input: current_user set to nil.
  • Expected Output: Returns false.
  • Validation: Ensure the method gracefully handles the absence of a logged-in user.
 
  context 'when user is not logged in' do
	before do
	  allow(controller).to receive(:current_user).and_return(nil)
	end

	it 'returns false' do
	  expect(controller.has_required_role?('Administrator')).to be false
	end
  end

Returns False When User Has No Role:

  • Input: current_user has nil for role.
  • Expected Output: Returns false.
  • Validation: Ensure the method handles cases where the user object exists but has no role assigned.
  context 'when user has no role' do
	before do
	  allow(user).to receive(:role).and_return(nil)
	end

	it 'returns false' do
	  expect(controller.has_required_role?('Administrator')).to be false
	end
  end

Tests for is_role?

Checks Exact Role Match with a String:

  • Input: role_name set to 'Student'.
  • Expected Output: Returns true if the current user’s role is exactly 'Student'.
  • Validation: Ensure the method matches the string representation of the role correctly.
 
  context 'when role_name is a string' do
	before do
	  allow(role).to receive(:name).and_return('Student')
	end

	it 'returns true when roles match' do
	  expect(controller.is_role?('Student')).to be true
	end

	it 'returns false when roles do not match' do
	  expect(controller.is_role?('Instructor')).to be false
	end
  end

Compares Role Names Using a Role Object:

  • Input: role_name as a Role object with the name 'Student'.
  • Expected Output: Returns true if the current user’s role name matches the role object name.
  • Validation: Ensure the method extracts the role name and performs the comparison accurately.
 
      context 'when role_name is a Role object' do
        let(:role_object) { instance_double('Role', name: 'Student') }

        before do
          allow(role).to receive(:name).and_return('Student')
          allow(role_object).to receive(:name).and_return('Student')
          allow(role_object).to receive(:is_a?).with(Role).and_return(true)
        end

        it 'compares using the role name' do
          expect(controller.is_role?(role_object)).to be true
        end
      end

Handles Missing User Gracefully:

  • Input: current_user is nil.
  • Expected Output: Returns false.
  • Validation: Ensure the method behaves as expected when there is no logged-in user.
      context 'when user is not logged in' do
        before do
          allow(controller).to receive(:current_user).and_return(nil)
        end

        it 'returns false' do
          expect(controller.is_role?('Student')).to be false
        end
      end

Handles User with No Role Gracefully:

  • Input: current_user has nil for role.
  • Expected Output: Returns false.
  • Validation: Ensure the method handles cases where the user exists but has no role.
      context 'when user has no role' do
        before do
          allow(user).to receive(:role).and_return(nil)
        end

        it 'returns false' do
          expect(controller.is_role?('Student')).to be false
        end
      end

Tests for all_actions_allowed?

Returns True for Users with Super Administrator Role:

  • Input: current_user has the role 'Super Administrator'.
  • Expected Output: Returns true.
  • Validation: Ensure that users with sufficient privileges bypass further checks.
 
      context 'when user has no role' do
        before do
          allow(user).to receive(:role).and_return(nil)
        end

        it 'returns false' do
          expect(controller.is_role?('Student')).to be false
        end
      end

Checks action_allowed? When User Lacks Administrator Role:

  • Input: current_user does not have the role 'Super Administrator'.
  • Expected Output: Returns false if action_allowed? also returns false.
  • Validation: Ensure the method correctly delegates to action_allowed? when necessary.
 
    context 'when the user does not have the Super Administrator role' do
      before do
        allow(controller).to receive(:has_required_role?).with('Super Administrator').and_return(false)
        allow(controller).to receive(:action_allowed?).and_return(false)
      end

      it 'checks action_allowed? and returns false' do
        expect(controller.all_actions_allowed?).to be false
      end
    end

Returns True When action_allowed? Returns True:

  • Input: action_allowed? returns true, even if has_required_role? returns false.
  • Expected Output: Returns true.
  • Validation: Ensure the method respects the result of action_allowed?.
 
    context 'when action_allowed? returns true' do
      before do
        allow(controller).to receive(:has_required_role?).with('Super Administrator').and_return(false)
        allow(controller).to receive(:action_allowed?).and_return(true)
      end

      it 'returns true' do
        expect(controller.all_actions_allowed?).to be true
      end
    end

Tests for authorize

Does Not Render Error for Authorized Users:

  • Input: all_actions_allowed? returns true.
  • Expected Output: No error response is rendered.
  • Validation: Ensure the method bypasses rendering when the user is authorized.
    context 'when all actions are allowed' do
      before do
        allow(controller).to receive(:all_actions_allowed?).and_return(true)
      end

      it 'does not render an error response' do
        expect(controller).not_to receive(:render)
        controller.authorize
      end
    end

Renders Forbidden Response for Unauthorized Actions:

  • Input: all_actions_allowed? returns false.
  • Expected Output: Renders a JSON response with an error message and a 403 Forbidden status.
  • Validation: Ensure the method renders the appropriate error message and status for unauthorized users.
    context 'when actions are not allowed' do
      before do
        allow(controller).to receive(:all_actions_allowed?).and_return(false)
        allow(controller.params).to receive(:[]).with(:action).and_return('edit')
        allow(controller.params).to receive(:[]).with(:controller).and_return('users')
      end

      it 'renders an error response with forbidden status' do
        expect(controller).to receive(:render).with(
          json: { error: "You are not authorized to edit this users" },
          status: :forbidden
        )
        controller.authorize
      end
    end


Tests for action_allowed?

Returns True by Default:

  • Input: No specific implementation of action_allowed?.
  • Expected Output: Returns true.
  • Validation: Ensure the default implementation allows all actions.
 
    it 'returns true by default' do
      expect(controller.action_allowed?).to be true
    end

Can Be Overridden for Custom Logic:

  • Input: Subclass with a custom action_allowed? implementation that returns false.
  • Expected Output: Returns false in the subclass.
  • Validation: Ensure the method can be overridden to provide custom authorization logic.
 
    it 'can be overridden in subclasses for custom logic' do
      class CustomController < ApplicationController
        include Authorization

        def action_allowed?
          false
        end
      end

      custom_controller = CustomController.new
      expect(custom_controller.action_allowed?).to be false
    end


Authorization Unit test stats

Integration Tests

Definition: Integration tests validate that the `AuthorizationHelper` module functions correctly within the context of the overall system.

Scenarios

Authenticated Access:

  • Requests to endpoints with valid JWT tokens should succeed.
  • Confirm correct permissions based on the user’s role.

Unauthorized Access:

  • Requests with invalid or missing tokens should fail.
  • Confirm proper error messages and HTTP status codes.

Role-Based Access Control:

  • Ensure users with sufficient roles can access specific actions.
  • Test granular access control for individual controllers.

Token Management:

  • Validate handling of expired, tampered, or revoked tokens.

Endpoints to Test

ApplicationController:

  • Validate authorize as a global before_action filter.

CoursesController:

  • We have changed the tests for the course flow in order to test the logic for authorization. We have initialized the dummy data before the tests and are running the tests for the courses to validate this. The tests for TA related activities under the course flow would fail as it would require refactoring in the way the course logic is written with respect to its interaction with TA
  before(:all) do
    # Create roles in hierarchy
    @super_admin = Role.find_or_create_by(name: 'Super Administrator')
    @admin = Role.find_or_create_by(name: 'Administrator', parent_id: @super_admin.id)
    @instructor = Role.find_or_create_by(name: 'Instructor', parent_id: @admin.id)
    @ta = Role.find_or_create_by(name: 'Teaching Assistant', parent_id: @instructor.id)
    @student = Role.find_or_create_by(name: 'Student', parent_id: @ta.id)
  end
  
  let(:prof) { User.create(
    name: "profa",
    password_digest: "password",
    role_id: @instructor.id,
    full_name: "Prof A",
    email: "testuser@example.com",
    mru_directory_path: "/home/testuser",
  ) }
  
  let(:token) { JsonWebToken.encode({id: prof.id}) }
  let(:Authorization) { "Bearer #{token}" }

Security Tests

Definition: Security tests focus on identifying vulnerabilities in the JWT authentication and authorization process.

Test Cases

  • Expired Tokens: Verify that expired tokens are rejected with appropriate error codes.
  • Tampered Tokens: Ensure tokens with altered signatures fail verification.
  • Token Revocation: Test token invalidation during user logout or role changes.
  • Injection Attacks: Validate inputs to ensure resilience against malicious payloads.

Pull Request

https://github.com/expertiza/reimplementation-back-end/pull/140/

Demo Video

https://go.ncsu.edu/oodd-final-oss-e2487

Team

Mentor

  • Kashika Mallick (kmallick@ncsu.edu)

Members

  • Shafa Hassan (shassa22@ncsu.edu)
  • Archit Gupta (agupta85@ncsu.edu)
  • Ansh Ganatra (aganatr@ncsu.edu)