github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/web/interceptor/access_control.go (about)

     1  package interceptor
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"strings"
     7  
     8  	"connectrpc.com/connect"
     9  	"github.com/quickfeed/quickfeed/qf"
    10  	"github.com/quickfeed/quickfeed/web/auth"
    11  )
    12  
    13  type (
    14  	role      int
    15  	roles     []role
    16  	requestID interface {
    17  		IDFor(string) uint64
    18  	}
    19  )
    20  
    21  const (
    22  	none role = iota
    23  	// user role implies that user attempts to access information about himself.
    24  	user
    25  	// group role implies that the user is a course student + a member of the given group.
    26  	group
    27  	// student role implies that the user is enrolled in the course with any role.
    28  	student
    29  	// teacher: user enrolled in the course with teacher status.
    30  	teacher
    31  	// admin is the user with admin privileges.
    32  	admin
    33  )
    34  
    35  // If there are several roles that can call a method, a role with the least privilege must come first.
    36  var accessRolesFor = map[string]roles{
    37  	"GetUser":                {none},
    38  	"GetCourse":              {none},
    39  	"GetCourses":             {none},
    40  	"SubmissionStream":       {none}, // No role required as long as the user is authenticated, i.e. has a valid token.
    41  	"CreateEnrollment":       {user},
    42  	"UpdateCourseVisibility": {user},
    43  	"UpdateUser":             {user, admin},
    44  	"GetEnrollments":         {user, student, teacher, admin},
    45  	"GetSubmissions":         {student, group, teacher},
    46  	"GetSubmission":          {teacher},
    47  	"CreateGroup":            {group, teacher},
    48  	"GetGroup":               {group, teacher},
    49  	"GetAssignments":         {student, teacher},
    50  	"GetRepositories":        {student, teacher},
    51  	"UpdateGroup":            {teacher},
    52  	"DeleteGroup":            {teacher},
    53  	"GetGroupsByCourse":      {teacher},
    54  	"UpdateCourse":           {teacher},
    55  	"UpdateEnrollments":      {teacher},
    56  	"UpdateAssignments":      {teacher},
    57  	"UpdateSubmission":       {teacher},
    58  	"UpdateSubmissions":      {teacher},
    59  	"RebuildSubmissions":     {teacher},
    60  	"CreateBenchmark":        {teacher},
    61  	"UpdateBenchmark":        {teacher},
    62  	"DeleteBenchmark":        {teacher},
    63  	"CreateCriterion":        {teacher},
    64  	"UpdateCriterion":        {teacher},
    65  	"DeleteCriterion":        {teacher},
    66  	"CreateReview":           {teacher},
    67  	"UpdateReview":           {teacher},
    68  	"IsEmptyRepo":            {teacher},
    69  	"GetSubmissionsByCourse": {teacher},
    70  	"GetUsers":               {admin},
    71  	"GetOrganization":        {admin},
    72  	"CreateCourse":           {admin},
    73  }
    74  
    75  type AccessControlInterceptor struct {
    76  	tokenManager *auth.TokenManager
    77  }
    78  
    79  func NewAccessControlInterceptor(tm *auth.TokenManager) *AccessControlInterceptor {
    80  	return &AccessControlInterceptor{tokenManager: tm}
    81  }
    82  
    83  func (*AccessControlInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
    84  	return connect.StreamingHandlerFunc(func(ctx context.Context, conn connect.StreamingHandlerConn) error {
    85  		return next(ctx, conn)
    86  	})
    87  }
    88  
    89  func (*AccessControlInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
    90  	return connect.StreamingClientFunc(func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn {
    91  		return next(ctx, spec)
    92  	})
    93  }
    94  
    95  // WrapUnary checks user information stored in the JWT claims against the list of roles required to call the method.
    96  func (a *AccessControlInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
    97  	return connect.UnaryFunc(func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
    98  		procedure := request.Spec().Procedure
    99  		method := procedure[strings.LastIndex(procedure, "/")+1:]
   100  		req, ok := request.Any().(requestID)
   101  		if !ok {
   102  			return nil, connect.NewError(connect.CodeUnimplemented,
   103  				fmt.Errorf("access denied for %s: message type %T does not implement 'requestID' interface", method, request))
   104  		}
   105  		claims, ok := auth.ClaimsFromContext(ctx)
   106  		if !ok {
   107  			return nil, connect.NewError(connect.CodePermissionDenied,
   108  				fmt.Errorf("access denied for %s: failed to get claims from request context", method))
   109  		}
   110  		for _, role := range accessRolesFor[method] {
   111  			switch role {
   112  			case none:
   113  				return next(ctx, request)
   114  			case user:
   115  				if claims.SameUser(req) {
   116  					// Make sure the user is not updating own admin status.
   117  					if method == "UpdateUser" {
   118  						if req.(*qf.User).GetIsAdmin() && !claims.Admin {
   119  							return nil, connect.NewError(connect.CodePermissionDenied,
   120  								fmt.Errorf("access denied for %s: user %d attempted to change admin status from %v to %v",
   121  									method, claims.UserID, claims.Admin, req.(*qf.User).GetIsAdmin()))
   122  						}
   123  					}
   124  					return next(ctx, request)
   125  				}
   126  			case student:
   127  				// GetSubmissions is used to fetch individual and group submissions.
   128  				// For individual submissions needs an extra check for user ID in request.
   129  				if method == "GetSubmissions" {
   130  					if req.IDFor("group") != 0 {
   131  						// Group submissions are handled by the group role.
   132  						continue
   133  					}
   134  					if !claims.SameUser(req) {
   135  						return nil, connect.NewError(connect.CodePermissionDenied,
   136  							fmt.Errorf("access denied for %s: ID mismatch in claims (%d) and request (%d)",
   137  								method, claims.UserID, req.IDFor("user")))
   138  					}
   139  				}
   140  				if claims.HasCourseStatus(req, qf.Enrollment_STUDENT) {
   141  					return next(ctx, request)
   142  				}
   143  			case group:
   144  				// Request for CreateGroup will not have ID yet, need to check
   145  				// if the user is in the group (unless teacher).
   146  				if method == "CreateGroup" {
   147  					notMember := !req.(*qf.Group).Contains(&qf.User{ID: claims.UserID})
   148  					notTeacher := !claims.HasCourseStatus(req, qf.Enrollment_TEACHER)
   149  					if notMember && notTeacher {
   150  						return nil, connect.NewError(connect.CodePermissionDenied,
   151  							fmt.Errorf("access denied for %s: user %d tried to create group while not teacher or group member", method, claims.UserID))
   152  					}
   153  					// Otherwise, create the group.
   154  					return next(ctx, request)
   155  				}
   156  				groupID := req.IDFor("group")
   157  				for _, group := range claims.Groups {
   158  					if group == groupID {
   159  						return next(ctx, request)
   160  					}
   161  				}
   162  			case teacher:
   163  				if method == "RebuildSubmissions" || method == "UpdateSubmission" {
   164  					if !isValidSubmission(a.tokenManager.Database(), req) {
   165  						return nil, connect.NewError(connect.CodePermissionDenied,
   166  							fmt.Errorf("access denied for %s: %v", method, "invalid submission"))
   167  					}
   168  				}
   169  				if claims.HasCourseStatus(req, qf.Enrollment_TEACHER) {
   170  					return next(ctx, request)
   171  				}
   172  			case admin:
   173  				if claims.Admin {
   174  					return next(ctx, request)
   175  				}
   176  			}
   177  		}
   178  		return nil, connect.NewError(connect.CodePermissionDenied,
   179  			fmt.Errorf("access denied for %s: required roles %v not satisfied by claims: %s", method, accessRolesFor[method], claims))
   180  	})
   181  }