package svc import ( "context" "errors" "fmt" "net/http" "net/url" "sort" "strings" "time" "github.com/CiscoM31/godata" libregraph "github.com/opencloud-eu/libre-graph-api-go" "github.com/qsfera/server/services/graph/pkg/errorcode" "github.com/qsfera/server/services/graph/pkg/identity" "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) // GetEducationSchools implements the Service interface. func (g Graph) GetEducationSchools(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Interface("query", r.URL.Query()).Msg("calling get schools") sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/") odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query()) if err != nil { logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools: query error") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } schools, err := g.getEducationSchoolsFromBackend(r.Context(), odataReq) if err != nil { logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools") renderEqualityFilterError(w, r, err) return } schools, err = sortEducationSchools(odataReq, schools) if err != nil { logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get schools: could not sort schools according to query") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: schools}) } // PostEducationSchool implements the Service interface. func (g Graph) PostEducationSchool(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Msg("calling post school") school := libregraph.NewEducationSchool() err := StrictJSONUnmarshal(r.Body, school) if err != nil { logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create school: invalid request body") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) return } // Disallow user-supplied IDs. It's supposed to be readonly. We're either // generating them in the backend ourselves or rely on the Backend's // storage (e.g. LDAP) to provide a unique ID. if _, ok := school.GetIdOk(); ok { logger.Debug().Msg("could not create school: id is a read-only attribute") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "school id is a read-only attribute") return } if _, ok := school.GetDisplayNameOk(); !ok { logger.Debug().Interface("school", school).Msg("could not create school: missing required attribute") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute") return } // validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means // termination date is to be deleted if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil { err = g.validateTerminationDate(*terminationDate) if err != nil { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) } return } if school, err = g.identityEducationBackend.CreateEducationSchool(r.Context(), *school); err != nil { logger.Debug().Err(err).Interface("school", school).Msg("could not create school: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes if school != nil && school.Id != nil { e := events.SchoolCreated{SchoolID: *school.Id} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) } */ render.Status(r, http.StatusCreated) render.JSON(w, r, school) } // PatchEducationSchool implements the Service interface. func (g Graph) PatchEducationSchool(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("calling patch school") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Str("id", schoolID).Msg("could not update school: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not update school: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } school := libregraph.NewEducationSchool() err = StrictJSONUnmarshal(r.Body, school) if err != nil { logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update school: invalid request body") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) return } // validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means // termination date is to be deleted if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil { err = g.validateTerminationDate(*terminationDate) if err != nil { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } } if school, err = g.identityEducationBackend.UpdateEducationSchool(r.Context(), schoolID, *school); err != nil { logger.Debug().Err(err).Interface("school", school).Msg("could not update school: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes e := events.SchoolFeatureChanged{SchoolID: schoolID} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) */ render.Status(r, http.StatusOK) render.JSON(w, r, school) } // GetEducationSchool implements the Service interface. func (g Graph) GetEducationSchool(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("calling get school") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Str("id", schoolID).Msg("could not get school: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") } if schoolID == "" { logger.Debug().Msg("could not get school: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } logger.Debug(). Str("id", schoolID). Interface("query", r.URL.Query()). Msg("calling get school on backend") school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID) if err != nil { logger.Debug().Err(err).Msg("could not get school: backend error") errorcode.RenderError(w, r, err) return } render.Status(r, http.StatusOK) render.JSON(w, r, school) } // DeleteEducationSchool implements the Service interface. func (g Graph) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("calling delete school") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not delete school: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } // Read school and check if termination date is set school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID) if err != nil { logger.Debug().Err(err).Msg("could not get school: backend error") errorcode.RenderError(w, r, err) return } termination, ok := school.GetTerminationDateOk() if !ok { logger.Debug().Msg("cannot delete school: not termination date set") errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "no termination date set") return } if time.Now().Before(*termination) { logger.Debug().Time("terminationDate", *termination).Msg("cannot delete school: termination date not reached") errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "can't delete school before termination date") return } logger.Debug().Str("schoolID", schoolID).Msg("Getting users of school") users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID) if err != nil { logger.Debug().Err(err).Msg("could not get school users: backend error") errorcode.RenderError(w, r, err) return } for _, user := range users { logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("calling delete member on backend") if err := g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, *user.Id); err != nil { if errors.Is(err, identity.ErrNotFound) { logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("user not found") continue } logger.Debug().Err(err).Msg("could not delete school member: backend error") errorcode.RenderError(w, r, err) // TODO Do we need return right hear? } } logger.Debug().Str("id", schoolID).Msg("calling delete school on backend") err = g.identityEducationBackend.DeleteEducationSchool(r.Context(), schoolID) if err != nil { logger.Debug().Err(err).Msg("could not delete school: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes e := events.SchoolDeleted{SchoolID: schoolID} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) */ render.Status(r, http.StatusNoContent) render.NoContent(w, r) } // GetEducationSchoolUsers implements the Service interface. func (g Graph) GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("calling get school users") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not get school users: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } logger.Debug().Str("id", schoolID).Msg("calling get school users on backend") users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID) if err != nil { logger.Debug().Err(err).Msg("could not get school users: backend error") errorcode.RenderError(w, r, err) return } render.Status(r, http.StatusOK) render.JSON(w, r, users) } // PostEducationSchoolUser implements the Service interface. func (g Graph) PostEducationSchoolUser(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("Calling post school user") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug(). Err(err). Str("id", schoolID). Msg("could not add user to school: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not add school user: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } memberRef := libregraph.NewMemberReference() err = StrictJSONUnmarshal(r.Body, memberRef) if err != nil { logger.Debug(). Err(err). Interface("body", r.Body). Msg("could not add school user: invalid request body") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) return } memberRefURL, ok := memberRef.GetOdataIdOk() if !ok { logger.Debug().Msg("could not add school user: @odata.id reference is missing") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing") return } memberType, id, err := g.parseMemberRef(*memberRefURL) if err != nil { logger.Debug().Err(err).Msg("could not add school user: error parsing @odata.id url") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url") return } // The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact" // we restrict this to users for now. Might add Schools as members later if memberType != "users" { logger.Debug().Str("type", memberType).Msg("could not add school user: Only users are allowed as school members") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as school members") return } logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add user on backend") err = g.identityEducationBackend.AddUsersToEducationSchool(r.Context(), schoolID, []string{id}) if err != nil { logger.Debug().Err(err).Msg("could not add school user: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) */ render.Status(r, http.StatusNoContent) render.NoContent(w, r) } // DeleteEducationSchoolUser implements the Service interface. func (g Graph) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") userID := chi.URLParam(r, "userID") logger.Info().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete school member") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school member: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not delete school member: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } userID, err = url.PathUnescape(userID) if err != nil { logger.Debug().Err(err).Str("id", userID).Msg("could not delete school member: unescaping member id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed") return } if userID == "" { logger.Debug().Msg("could not delete school member: missing member id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id") return } logger.Debug().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete member on backend") err = g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, userID) if err != nil { logger.Debug().Err(err).Msg("could not delete school member: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) */ render.Status(r, http.StatusNoContent) render.NoContent(w, r) } // GetEducationSchoolClasses implements the Service interface. func (g Graph) GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("calling get school classes") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not get school users: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } logger.Debug().Str("id", schoolID).Msg("calling get school classes on backend") classes, err := g.identityEducationBackend.GetEducationSchoolClasses(r.Context(), schoolID) if err != nil { logger.Debug().Err(err).Msg("could not get school classes: backend error") errorcode.RenderError(w, r, err) return } render.Status(r, http.StatusOK) render.JSON(w, r, classes) } // PostEducationSchoolClass implements the Service interface. func (g Graph) PostEducationSchoolClass(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") logger.Info().Str("schoolID", schoolID).Msg("Calling post school class") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug(). Err(err). Str("id", schoolID). Msg("could not add class to school: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not add school class: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } memberRef := libregraph.NewMemberReference() err = StrictJSONUnmarshal(r.Body, memberRef) if err != nil { logger.Debug(). Err(err). Interface("body", r.Body). Msg("could not add school class: invalid request body") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) return } memberRefURL, ok := memberRef.GetOdataIdOk() if !ok { logger.Debug().Msg("could not add school class: @odata.id reference is missing") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing") return } memberType, id, err := g.parseMemberRef(*memberRefURL) if err != nil { logger.Debug().Err(err).Msg("could not add school class: error parsing @odata.id url") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url") return } // The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact" // we restrict this to users for now. Might add Schools as members later if memberType != "classes" { logger.Debug().Str("type", memberType).Msg("could not add school class: Only classes are allowed as school members") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only classes are allowed as school members") return } logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add class on backend") err = g.identityEducationBackend.AddClassesToEducationSchool(r.Context(), schoolID, []string{id}) if err != nil { logger.Debug().Err(err).Msg("could not add school class: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) */ render.Status(r, http.StatusNoContent) render.NoContent(w, r) } // DeleteEducationSchoolClass implements the Service interface. func (g Graph) DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) schoolID := chi.URLParam(r, "schoolID") classID := chi.URLParam(r, "classID") logger.Info().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete school class") schoolID, err := url.PathUnescape(schoolID) if err != nil { logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school class: unescaping school id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") return } if schoolID == "" { logger.Debug().Msg("could not delete school class: missing school id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") return } classID, err = url.PathUnescape(classID) if err != nil { logger.Debug().Err(err).Str("id", classID).Msg("could not delete school class: unescaping class id failed") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") return } if classID == "" { logger.Debug().Msg("could not delete school class: missing class id") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") return } logger.Debug().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete class on backend") err = g.identityEducationBackend.RemoveClassFromEducationSchool(r.Context(), schoolID, classID) if err != nil { logger.Debug().Err(err).Msg("could not delete school class: backend error") errorcode.RenderError(w, r, err) return } /* TODO requires reva changes e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID} if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { e.Executant = currentUser.GetId() } g.publishEvent(e) */ render.Status(r, http.StatusNoContent) render.NoContent(w, r) } func (g Graph) validateTerminationDate(terminationDate time.Time) error { if terminationDate.Before(time.Now()) { return fmt.Errorf("can not set a termination date in the past") } graceDays := g.config.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays if graceDays != 0 { if terminationDate.Before(time.Now().Add(time.Duration(graceDays) * 24 * time.Hour)) { return fmt.Errorf("termination needs to be at least %d day(s) in the future", graceDays) } } return nil } func sortEducationSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchool) ([]*libregraph.EducationSchool, error) { if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 { return schools, nil } var less func(i, j int) bool switch req.Query.OrderBy.OrderByItems[0].Field.Value { case displayNameAttr: less = func(i, j int) bool { return strings.ToLower(schools[i].GetDisplayName()) < strings.ToLower(schools[j].GetDisplayName()) } default: return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value) } if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending { sort.Slice(schools, reverse(less)) } else { sort.Slice(schools, less) } return schools, nil } // getEducationSchoolsFromBackend fetches schools from the backend, applying an OData $filter if present. func (g Graph) getEducationSchoolsFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationSchool, error) { if odataReq.Query.Filter != nil { attr, value, err := g.getEqualityFilter(ctx, odataReq) if err != nil { return nil, err } return g.identityEducationBackend.FilterEducationSchoolsByAttribute(ctx, attr, value) } return g.identityEducationBackend.GetEducationSchools(ctx) }