diff --git a/classes/manage.go b/classes/manage.go new file mode 100644 index 0000000..3cc8bfc --- /dev/null +++ b/classes/manage.go @@ -0,0 +1,97 @@ +package classes + +// Owner-side management behind the manage routes under /api/v1/quizzes, +// which the MCP quiz tools bind. There is no owner column: a user whose +// groups grant golem15.quizzes.access_quizzes manages every quiz, as in the +// admin (A7). Quizzes are addressed by slug. + +import ( + "context" + "errors" + + "git.golem15.com/golem15/sm-quizzes-plugin/models" + "gorm.io/gorm" +) + +// ManagedQuizRow is one quiz of the owner list with its question count and +// the number of completed attempts (of every user). +type ManagedQuizRow struct { + Quiz models.Quiz + QuestionCount int + AttemptsCount int +} + +// ListManagedQuizzes returns every quiz, published or not, optionally only +// those whose context equals quizContext, ordered by context (quizzes +// without one last), title and id. +func ListManagedQuizzes(ctx context.Context, db *gorm.DB, quizContext string) ([]ManagedQuizRow, error) { + db = db.WithContext(ctx) + q := db.Model(&models.Quiz{}) + if quizContext != "" { + q = q.Where("context = ?", quizContext) + } + var quizzes []models.Quiz + if err := q.Order("context ASC NULLS LAST, title ASC, id ASC").Find(&quizzes).Error; err != nil { + return nil, err + } + out := make([]ManagedQuizRow, 0, len(quizzes)) + if len(quizzes) == 0 { + return out, nil + } + ids := make([]uint, len(quizzes)) + for i, quiz := range quizzes { + ids[i] = quiz.ID + } + type countRow struct { + QuizID uint + N int + } + var questions, attempts []countRow + if err := db.Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n"). + Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&questions).Error; err != nil { + return nil, err + } + if err := db.Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n"). + Where("quiz_id IN ? AND completed_at IS NOT NULL", ids).Group("quiz_id").Scan(&attempts).Error; err != nil { + return nil, err + } + questionsBy := map[uint]int{} + for _, c := range questions { + questionsBy[c.QuizID] = c.N + } + attemptsBy := map[uint]int{} + for _, c := range attempts { + attemptsBy[c.QuizID] = c.N + } + for _, quiz := range quizzes { + out = append(out, ManagedQuizRow{Quiz: quiz, QuestionCount: questionsBy[quiz.ID], AttemptsCount: attemptsBy[quiz.ID]}) + } + return out, nil +} + +// ManagedQuiz loads a quiz, published or not, with every question in sort +// order and every answer of each, solutions included. A missing slug is +// ErrNotFound. +func ManagedQuiz(ctx context.Context, db *gorm.DB, slug string) (*models.Quiz, error) { + var quiz models.Quiz + err := db.WithContext(ctx). + Preload("Questions", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }). + Preload("Questions.Answers", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }). + Where("slug = ?", slug).Take(&quiz).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &quiz, nil +} + +// CompletedAttemptCount counts the completed attempts of every user on a +// quiz. +func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int, error) { + var n int64 + err := db.WithContext(ctx).Model(&models.Attempt{}). + Where("quiz_id = ? AND completed_at IS NOT NULL", quizID).Count(&n).Error + return int(n), err +} diff --git a/controllers/api/manage.go b/controllers/api/manage.go new file mode 100644 index 0000000..7fbdb39 --- /dev/null +++ b/controllers/api/manage.go @@ -0,0 +1,89 @@ +package api + +// The owner manage routes under /api/v1/quizzes, behind user.api_token, the +// golem15.quizzes.manage throttle, a token scope and the access_quizzes +// permission. The MCP quiz tools bind them. Answers use the owner shapes of +// manage_json.go, which carry the solutions. + +import ( + "errors" + "net/http" + "strings" + + "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// manageDB returns the application database, or answers 503 as the student +// routes do without one. +func (h *Handlers) manageDB(w http.ResponseWriter) (*gorm.DB, bool) { + var gdb *gorm.DB + if h.App != nil { + gdb, _ = h.App.Lookup[*gorm.DB]() + } + if gdb == nil { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"}) + return nil, false + } + return gdb, true +} + +// writeManaged answers {data: data}, or maps err: ErrNotFound is a 404, +// ErrQuizExists a 409, anything else an opaque 500. +func writeManaged(w http.ResponseWriter, status int, data any, err error) { + switch { + case err == nil: + wire.WriteJSON(w, status, map[string]any{"data": data}) + case errors.Is(err, classes.ErrNotFound): + wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Quiz not found."}) + case errors.Is(err, classes.ErrQuizExists): + wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": "A quiz with this slug already exists."}) + default: + wire.WriteOpaque500(w) + } +} + +// ManageListQuizzes answers GET /api/v1/quizzes?context=: every quiz, +// published or not, with its question and completed-attempt counts. +func (h *Handlers) ManageListQuizzes(w http.ResponseWriter, r *http.Request) { + db, ok := h.manageDB(w) + if !ok { + return + } + rows, err := classes.ListManagedQuizzes(r.Context(), db, strings.TrimSpace(r.URL.Query().Get("context"))) + if err != nil { + writeManaged(w, 0, nil, err) + return + } + out := make([]managedQuizJSON, 0, len(rows)) + for _, row := range rows { + out = append(out, managedQuizRow(row)) + } + writeManaged(w, http.StatusOK, out, nil) +} + +// ManageShowQuiz answers GET /api/v1/quizzes/{slug}: the quiz with every +// question and answer, solutions included. +func (h *Handlers) ManageShowQuiz(w http.ResponseWriter, r *http.Request) { + db, ok := h.manageDB(w) + if !ok { + return + } + h.writeManagedQuiz(w, r, db, http.StatusOK, r.PathValue("slug")) +} + +// writeManagedQuiz answers the quiz with its questions and solutions. +func (h *Handlers) writeManagedQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, slug string) { + quiz, err := classes.ManagedQuiz(r.Context(), db, slug) + if err != nil { + writeManaged(w, 0, nil, err) + return + } + attempts, err := classes.CompletedAttemptCount(r.Context(), db, quiz.ID) + if err != nil { + writeManaged(w, 0, nil, err) + return + } + writeManaged(w, status, managedQuizDetail(*quiz, attempts), nil) +} diff --git a/controllers/api/manage_json.go b/controllers/api/manage_json.go new file mode 100644 index 0000000..7394998 --- /dev/null +++ b/controllers/api/manage_json.go @@ -0,0 +1,116 @@ +package api + +// The owner shapes of the manage routes. They carry the solutions +// (is_correct, explanations, answer_text) and are never used by a student +// route; the student shapes live in json.go. Built field by field, so a new +// column never leaks. + +import ( + "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/sm-quizzes-plugin/models" + "git.golem15.com/golem15/summercms/modules/wire" +) + +type managedQuizJSON struct { + ID uint `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Description *string `json:"description"` + Context *string `json:"context"` + Mode string `json:"mode"` + Navigation string `json:"navigation"` + MaxAttempts *int `json:"max_attempts"` + TimeLimitSeconds *int `json:"time_limit_seconds"` + PassingScore int `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + RoundSize *int `json:"round_size"` + IsPublished bool `json:"is_published"` + QuestionCount int `json:"question_count"` + AttemptsCount int `json:"attempts_count"` + CreatedAt wire.Time `json:"created_at"` + UpdatedAt wire.Time `json:"updated_at"` +} + +type managedQuizDetailJSON struct { + managedQuizJSON + Questions []managedQuestionJSON `json:"questions"` +} + +type managedQuestionJSON struct { + ID uint `json:"id"` + Text string `json:"text"` + Type string `json:"type"` + Category *string `json:"category"` + AnswerText *string `json:"answer_text"` + Explanation *string `json:"explanation"` + Points int `json:"points"` + SortOrder int `json:"sort_order"` + Answers []managedAnswerJSON `json:"answers"` +} + +type managedAnswerJSON struct { + ID uint `json:"id"` + Text string `json:"text"` + IsCorrect bool `json:"is_correct"` + Explanation *string `json:"explanation"` + SortOrder int `json:"sort_order"` +} + +func managedQuiz(q models.Quiz, questions, attempts int) managedQuizJSON { + return managedQuizJSON{ + ID: q.ID, + Slug: q.Slug, + Title: q.Title, + Description: q.Description, + Context: q.Context, + Mode: quizMode(&q), + Navigation: quizNavigation(&q), + MaxAttempts: q.MaxAttempts, + TimeLimitSeconds: q.TimeLimitSeconds, + PassingScore: q.PassingScore, + ShuffleQuestions: q.ShuffleQuestions, + RoundSize: q.RoundSize, + IsPublished: q.IsPublished, + QuestionCount: questions, + AttemptsCount: attempts, + CreatedAt: wireTime(q.CreatedAt), + UpdatedAt: wireTime(q.UpdatedAt), + } +} + +func managedQuizRow(row classes.ManagedQuizRow) managedQuizJSON { + return managedQuiz(row.Quiz, row.QuestionCount, row.AttemptsCount) +} + +// managedQuizDetail is a quiz loaded by classes.ManagedQuiz with its +// questions; attempts is its completed-attempt count. +func managedQuizDetail(q models.Quiz, attempts int) managedQuizDetailJSON { + out := managedQuizDetailJSON{ + managedQuizJSON: managedQuiz(q, len(q.Questions), attempts), + Questions: make([]managedQuestionJSON, 0, len(q.Questions)), + } + for _, question := range q.Questions { + out.Questions = append(out.Questions, managedQuestion(question)) + } + return out +} + +func managedQuestion(q models.Question) managedQuestionJSON { + out := managedQuestionJSON{ + ID: q.ID, + Text: q.Text, + Type: q.Type, + Category: q.Category, + AnswerText: q.AnswerText, + Explanation: q.Explanation, + Points: q.Points, + SortOrder: q.SortOrder, + Answers: make([]managedAnswerJSON, 0, len(q.Answers)), + } + for _, a := range q.Answers { + out.Answers = append(out.Answers, managedAnswerJSON{ + ID: a.ID, Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: a.SortOrder, + }) + } + return out +} diff --git a/controllers/api/manage_test.go b/controllers/api/manage_test.go new file mode 100644 index 0000000..43a68db --- /dev/null +++ b/controllers/api/manage_test.go @@ -0,0 +1,22 @@ +package api + +import ( + "net/http" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" +) + +// TestManageHandlersWithoutDatabase: every manage handler answers 503 +// when the application has no database. +func TestManageHandlersWithoutDatabase(t *testing.T) { + h := New(backpack.New(nil)) + for name, fn := range map[string]http.HandlerFunc{ + "list": h.ManageListQuizzes, "show": h.ManageShowQuiz, + } { + code, _ := call(t, fn, "GET", "/", "", 7, map[string]string{"slug": "x"}) + if code != http.StatusServiceUnavailable { + t.Errorf("%s without a database = %d, want 503", name, code) + } + } +} diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..57c6f7d --- /dev/null +++ b/mcp.go @@ -0,0 +1,14 @@ +package quizzes + +import "git.golem15.com/golem15/summercms/modules/pact" + +var _ pact.HasMCP = (*Plugin)(nil) + +// MCP declares the owner's quiz tools (exposed as quizzes_*). Every tool +// lists the golem15.quizzes.access_quizzes permission, so a user whose +// groups lack it never lists them, and every route the tools bind checks +// it again (D-13). The plugin still requires only golem15.user and names +// no OAuth server. +func (p *Plugin) MCP() pact.MCP { + return pact.MCP{Tools: quizTools()} +} diff --git a/mcp_quizzes.go b/mcp_quizzes.go new file mode 100644 index 0000000..88f4eeb --- /dev/null +++ b/mcp_quizzes.go @@ -0,0 +1,53 @@ +package quizzes + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// The sentences every quiz tool description ends with (D-13). +const ( + needsRead = " Needs scope read and the golem15.quizzes.access_quizzes permission." + needsWrite = " Needs scope write and the golem15.quizzes.access_quizzes permission." +) + +// quizPermissions filters the listing: only users whose groups grant the +// quizzes permission see the quiz tools. +var quizPermissions = []string{permQuizzes} + +// yes is the true the tool annotations point at. +var yes = true + +// listQuizzesInput is the input of quizzes_list_quizzes. +type listQuizzesInput struct { + Context string `json:"context,omitempty" query:"context" jsonschema:"only quizzes whose context label equals this value exactly; leave out for every quiz"` +} + +// showQuizInput is the input of quizzes_show_quiz. +type showQuizInput struct { + Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug (lowercase letters, digits and hyphens), as listed by quizzes_list_quizzes"` +} + +func quizTools() []pact.MCPTool { + return []pact.MCPTool{ + { + Name: "list_quizzes", + Title: "List quizzes", + Description: "Lists every quiz, published or not, ordered by context (quizzes without one last) and title: id, slug, title, description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, is_published, question_count, attempts_count (completed attempts of all students), created_at and updated_at. context filters by the exact context label." + needsRead, + Method: http.MethodGet, + Path: ManagePrefix, + Input: listQuizzesInput{}, + Permissions: quizPermissions, + }, + { + Name: "show_quiz", + Title: "Show a quiz with its solutions", + Description: "Returns one quiz with the fields of quizzes_list_quizzes and its questions in order: id, text, type (single, multiple or self_check), category, answer_text, explanation, points, sort_order and answers [{id, text, is_correct, explanation, sort_order}]. This is the owner view: it includes the solutions (is_correct, explanations and answer_text), which students only see after answering or finishing." + needsRead, + Method: http.MethodGet, + Path: ManagePrefix + "/{slug}", + Input: showQuizInput{}, + Permissions: quizPermissions, + }, + } +} diff --git a/mcp_test.go b/mcp_test.go new file mode 100644 index 0000000..fff0e8a --- /dev/null +++ b/mcp_test.go @@ -0,0 +1,184 @@ +package quizzes + +import ( + "net/http" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "unicode/utf8" + + _ "git.golem15.com/golem15/sm-user-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/pier" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// mountHost adapts a built surf router to pier.Host for a compile-only +// mount: pier compiles every declared tool against the real route table. +type mountHost struct{ r *surf.Router } + +func (h mountHost) Routes() []pier.RouteInfo { + var out []pier.RouteInfo + for _, ri := range h.r.Routes() { + out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware}) + } + return out +} +func (h mountHost) Handler(method, pattern string) (http.Handler, bool) { + return h.r.Handler(method, pattern) +} +func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil } +func (mountHost) MountJSON(string, string, http.Handler) error { return nil } +func (h mountHost) Owner(method, path string) (string, bool) { + for _, ri := range h.r.Routes() { + if ri.Method == method && ri.Pattern == path { + return ri.PluginID, true + } + } + return "", false +} + +// mcpTestApp boots golem15.user and golem15.quizzes on a config without a +// database. +func mcpTestApp(t *testing.T) (*backpack.App, []party.Plugin) { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "app.yaml": "name: quizzes-mcp\nlocale: en\nfallback_locale: en\nurl: https://quizzes.example.test\n", + "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n", + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{ + "SUMMER_ENV=testing", + "SUMMER_GOLEM15__USER__JWT__SECRET=quizzes-test-only-user-jwt-secret-with-32", + "SUMMER_ADMIN__JWT__SECRET=quizzes-test-only-admin-jwt-secret-with-32", + }}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + plugins, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes"}) + if err != nil { + t.Fatalf("Activate: %v", err) + } + return app, plugins +} + +// pinnedTools is the sorted tool list of golem15.user plus this plugin. +var pinnedTools = []string{ + "quizzes_list_quizzes", + "quizzes_show_quiz", + "user_me", +} + +// TestQuizzesMCP (D-13, D-14) mounts pier against golem15.user and +// golem15.quizzes and pins the compiled tools; every quiz tool lists the +// quizzes permission and says so, GET and DELETE tools carry no body, and +// only import_quiz can carry is_published. Runs under -short. +func TestQuizzesMCP(t *testing.T) { + tools := (&Plugin{}).MCP().Tools + + t.Run("compiles", func(t *testing.T) { + app, plugins := mcpTestApp(t) + router, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatalf("BuildRouter: %v", err) + } + if err := app.Config.Set(pier.EnabledKey, true); err != nil { + t.Fatal(err) + } + if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil { + t.Fatalf("pier.Mount: %v", err) + } + p, ok := app.Lookup[*pier.Pier]() + if !ok { + t.Fatal("pier was not mounted") + } + if got := p.ToolNames(); !slices.Equal(got, pinnedTools) { + t.Fatalf("compiled tools %v, want %v", got, pinnedTools) + } + }) + + t.Run("permissions", func(t *testing.T) { + for _, tool := range tools { + if !slices.Contains(tool.Permissions, permQuizzes) { + t.Errorf("%s: Permissions %v lack %s", tool.Name, tool.Permissions, permQuizzes) + } + if !strings.Contains(tool.Description, "Needs scope") || !strings.Contains(tool.Description, "golem15.quizzes.access_quizzes") { + t.Errorf("%s: description does not name its scope and permission: %q", tool.Name, tool.Description) + } + } + }) + + t.Run("inputs", func(t *testing.T) { + for _, tool := range tools { + rt := reflect.TypeOf(tool.Input) + for rt.Kind() == reflect.Pointer { + rt = rt.Elem() + } + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + switch { + case f.Tag.Get("path") != "": + if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") { + t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path) + } + case f.Tag.Get("query") != "": + default: + if tool.Method == http.MethodGet || tool.Method == http.MethodDelete { + t.Errorf("%s.%s: a %s tool carries a body field", tool.Name, f.Name, tool.Method) + } + } + } + key, found := jsonKeyIn(rt, "is_published") + if found != (tool.Name == "import_quiz") { + t.Errorf("%s: has a field named %s = %v, want %v", tool.Name, key, found, tool.Name == "import_quiz") + } + } + }) + + t.Run("descriptions", func(t *testing.T) { + for _, tool := range tools { + if n := utf8.RuneCountInString(tool.Description); n > pier.MaxDescriptionRunes { + t.Errorf("%s: description has %d runes, more than %d", tool.Name, n, pier.MaxDescriptionRunes) + } + } + }) +} + +// jsonKeyIn reports whether rt, or a struct it contains (through pointers, +// slices and nested structs), has a field with the given JSON key. +func jsonKeyIn(rt reflect.Type, key string) (string, bool) { + seen := map[reflect.Type]bool{} + var walk func(reflect.Type) bool + walk = func(t reflect.Type) bool { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { + t = t.Elem() + } + if t.Kind() != reflect.Struct || seen[t] { + return false + } + seen[t] = true + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == key || (name == "" && strings.EqualFold(f.Name, strings.ReplaceAll(key, "_", ""))) { + return true + } + if walk(f.Type) { + return true + } + } + return false + } + return key, walk(rt) +} diff --git a/plugin.go b/plugin.go index f505894..afb15bc 100644 --- a/plugin.go +++ b/plugin.go @@ -4,14 +4,19 @@ import ( "context" "embed" "io/fs" + "net/http" + "strconv" + "time" "git.golem15.com/golem15/sm-quizzes-plugin/console" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) @@ -28,6 +33,9 @@ var ( _ pact.AdminAssets = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) _ pact.HasNavigation = (*Plugin)(nil) + + _ pact.HasFrontendPermissions = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) ) //go:embed config @@ -115,11 +123,43 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } // Permissions declares the admin permissions. func (p *Plugin) Permissions() []pact.Permission { return []pact.Permission{ - {Code: "golem15.quizzes.access_quizzes", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"}, + {Code: permQuizzes, Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"}, {Code: "golem15.quizzes.access_attempts", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_attempts"}, } } +// FrontendPermissions declares the code the owner manage routes check (the +// MCP quiz tools), so golem15.user's User Groups editor can grant it. +// Declaring it grants nothing by itself. +func (p *Plugin) FrontendPermissions() []pact.FrontendPermission { + return []pact.FrontendPermission{{ + Code: permQuizzes, + Label: "golem15.quizzes::lang.permissions.access_quizzes", + Tab: "golem15.quizzes::lang.plugin.name", + }} +} + +// Buckets declares the per-user rate limit of the owner manage routes: +// building a quiz question by question is chatty, so 120 calls a minute. +// A request without a user (never after user.api_token) keys by client IP. +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p != nil && p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + byUser := func(prefix string) func(*http.Request) string { + return func(r *http.Request) string { + if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 { + return prefix + "|u:" + strconv.FormatUint(uint64(u.ID), 10) + } + return prefix + "|ip:" + surf.ClientIP(r, trusted) + } + } + return map[string]surf.Bucket{ + bucketManage: {Max: 120, Decay: time.Minute, Key: byUser(bucketManage)}, + } +} + // Navigation declares the "Quizzes" admin menu with its side menu. func (p *Plugin) Navigation() []pact.NavigationItem { return []pact.NavigationItem{{ diff --git a/plugin_test.go b/plugin_test.go index 5c6e672..39dfbd3 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -69,6 +69,8 @@ func TestRoutesRegistered(t *testing.T) { "POST /_quizzes/api/v1/attempts/{id}/answers", "POST /_quizzes/api/v1/attempts/{id}/finish", "POST /api/v1/quizzes/import", + "GET /api/v1/quizzes", + "GET /api/v1/quizzes/{slug}", } var got []string for _, r := range router.Routes() { @@ -84,6 +86,17 @@ func TestRoutesRegistered(t *testing.T) { } continue } + if r.Pattern == "/api/v1/quizzes" || strings.HasPrefix(r.Pattern, "/api/v1/quizzes/") { + for _, mw := range []string{"user.api_token", "throttle:golem15.quizzes.manage", "user.permission:golem15.quizzes.access_quizzes"} { + if !slices.Contains(r.Middleware, mw) { + t.Errorf("%s %s is not behind %s: %v", r.Method, r.Pattern, mw, r.Middleware) + } + } + if !slices.Contains(r.Middleware, "user.scope:read") && !slices.Contains(r.Middleware, "user.scope:write") { + t.Errorf("%s %s has no token scope: %v", r.Method, r.Pattern, r.Middleware) + } + continue + } if !slices.Contains(r.Middleware, "jwt.auth") { t.Errorf("%s %s is not behind jwt.auth: %v", r.Method, r.Pattern, r.Middleware) } diff --git a/routes.go b/routes.go index a235aea..447bada 100644 --- a/routes.go +++ b/routes.go @@ -15,9 +15,15 @@ const ManagePrefix = "/api/v1/quizzes" // admin and through the management API. const permQuizzes = "golem15.quizzes.access_quizzes" +// bucketManage limits the owner manage routes (the MCP quiz tools). It runs +// after user.api_token, so it keys by user. +const bucketManage = "golem15.quizzes.manage" + // Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's // jwt.auth middleware, and the management API under /api/v1/quizzes behind -// its application-wide API token. +// its application-wide API token: the import route, and the owner manage +// routes the MCP quiz tools bind, which also need the access_quizzes +// permission and run under the per-user golem15.quizzes.manage throttle. func (p *Plugin) Routes(r pact.Router) error { h := api.New(p.app) r.Group("/_quizzes/api/v1", surf.Use("jwt.auth"), func(g pact.Router) { @@ -39,5 +45,14 @@ func (p *Plugin) Routes(r pact.Router) error { r.Group(ManagePrefix, surf.Use("throttle:60,1", "user.api_token"), func(g pact.Router) { g.Post("/import", h.ImportQuiz, "user.scope:write", "user.permission:"+permQuizzes) }) + // The owner manage routes (MCP). user.api_token runs first so the + // bucket keys by user; every route needs the permission, so a student + // whose groups lack it never lists or reaches these tools. + perm := "user.permission:" + permQuizzes + r.Group(ManagePrefix, surf.Use("user.api_token", "throttle:"+bucketManage), func(g pact.Router) { + g.Get("", h.ManageListQuizzes, "user.scope:read", perm) + g.Get("/{slug}", h.ManageShowQuiz, "user.scope:read", perm) + g.Where("slug", `[a-z0-9-]+`) + }) return nil }