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 }