diff --git a/cmd/server/main.go b/cmd/server/main.go index 25c33bb..57df8a5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" + commonaudit "gitea.estateliga.work/admin/portal-common/audit" commondb "gitea.estateliga.work/admin/portal-common/db" commonmw "gitea.estateliga.work/admin/portal-common/middleware" @@ -72,6 +73,11 @@ func main() { lessonRepo := repository.NewLessonRepository(pool) publicTokenRepo := repository.NewPublicTokenRepository(pool) accessRepo := repository.NewAccessGrantRepository(pool) + auditClient := commonaudit.NewClient(cfg.PortalURL, cfg.InternalAPIKey) + if !auditClient.Enabled() { + slog.Warn("portal audit client disabled — business audit events выключены", + "portal_url_set", cfg.PortalURL != "", "portal_key_set", cfg.InternalAPIKey != "") + } healthH := handler.NewHealthHandler(pool) testH := handler.NewTestHandler(testRepo, accessRepo) @@ -91,6 +97,7 @@ func main() { r.Route("/api", func(r chi.Router) { r.Use(commonmw.InternalAuth(cfg.InternalAPIKey)) + r.Use(handler.NewAuditMiddleware(auditClient).Middleware) // Tests CRUD r.Get("/tests", testH.List) diff --git a/go.mod b/go.mod index 58924a7..00510c9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module learning-service go 1.25.7 require ( - gitea.estateliga.work/admin/portal-common v0.2.0 + gitea.estateliga.work/admin/portal-common v0.3.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.1 diff --git a/go.sum b/go.sum index 175b72a..fe68cfa 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE= -gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk= +gitea.estateliga.work/admin/portal-common v0.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM= +gitea.estateliga.work/admin/portal-common v0.3.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/internal/handler/audit.go b/internal/handler/audit.go new file mode 100644 index 0000000..fd9f03c --- /dev/null +++ b/internal/handler/audit.go @@ -0,0 +1,279 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + chimw "github.com/go-chi/chi/v5/middleware" + + commonaudit "gitea.estateliga.work/admin/portal-common/audit" + commonmw "gitea.estateliga.work/admin/portal-common/middleware" +) + +type AuditMiddleware struct { + client *commonaudit.Client +} + +func NewAuditMiddleware(client *commonaudit.Client) *AuditMiddleware { + return &AuditMiddleware{client: client} +} + +func (m *AuditMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if m == nil || m.client == nil || !m.client.Enabled() || !isAuditWriteMethod(r.Method) { + next.ServeHTTP(w, r) + return + } + + rec := &auditResponseRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + next.ServeHTTP(rec, r) + if rec.status < 200 || rec.status >= 300 { + return + } + + action := matchLearningAuditAction(r.Method, r.URL.Path) + if action.Action == "" { + return + } + if action.EntityID == "" { + action.EntityID = extractAuditEntityID(rec.body.Bytes()) + } + details := map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "status": rec.status, + "duration_ms": time.Since(start).Milliseconds(), + } + if rid := chimw.GetReqID(r.Context()); rid != "" { + details["request_id"] = rid + } + if q := r.URL.RawQuery; q != "" { + details["query"] = q + } + + event := commonaudit.Event{ + Action: action.Action, + EntityType: action.EntityType, + EntityID: action.EntityID, + UserID: commonmw.GetUserID(r.Context()), + UserName: commonmw.GetUserName(r.Context()), + IPAddress: commonmw.GetClientIP(r.Context()), + Details: details, + } + go func() { + if err := m.client.Send(context.Background(), event); err != nil { + slog.Warn("learning audit send failed", "error", err, "action", event.Action, "entity_id", event.EntityID) + } + }() + }) +} + +type learningAuditAction struct { + Action string + EntityType string + EntityID string +} + +type auditResponseRecorder struct { + http.ResponseWriter + status int + body bytes.Buffer +} + +func (r *auditResponseRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func (r *auditResponseRecorder) Write(body []byte) (int, error) { + if r.body.Len() < 64*1024 { + _, _ = r.body.Write(body) + } + return r.ResponseWriter.Write(body) +} + +func isAuditWriteMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete: + return true + default: + return false + } +} + +func matchLearningAuditAction(method, path string) learningAuditAction { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) > 0 && parts[0] == "api" { + parts = parts[1:] + } + if len(parts) == 0 { + return learningAuditAction{} + } + switch parts[0] { + case "tests": + return matchTestAuditAction(method, parts) + case "attempts": + return matchAttemptAuditAction(method, parts) + case "courses": + return matchCourseAuditAction(method, parts) + case "lessons": + return matchLessonAuditAction(method, parts) + case "access": + return matchAccessAuditAction(method, parts) + case "public-tokens": + return matchPublicTokenAuditAction(method, parts) + default: + return learningAuditAction{} + } +} + +func matchTestAuditAction(method string, parts []string) learningAuditAction { + if method == http.MethodPost && len(parts) == 1 { + return learningAuditAction{"learning.test_create", "learning_test", ""} + } + if len(parts) < 2 { + return learningAuditAction{} + } + testID := parts[1] + if len(parts) == 2 { + switch method { + case http.MethodPatch: + return learningAuditAction{"learning.test_update", "learning_test", testID} + case http.MethodDelete: + return learningAuditAction{"learning.test_delete", "learning_test", testID} + } + } + if len(parts) >= 3 { + switch parts[2] { + case "questions": + return matchQuestionAuditAction(method, parts, testID) + case "attempts": + if method == http.MethodPost && len(parts) == 3 { + return learningAuditAction{"learning.attempt_start", "learning_test", testID} + } + } + } + return learningAuditAction{} +} + +func matchQuestionAuditAction(method string, parts []string, testID string) learningAuditAction { + if method == http.MethodPost && len(parts) == 3 { + return learningAuditAction{"learning.question_create", "learning_test", testID} + } + if method == http.MethodPost && len(parts) == 4 && parts[3] == "reorder" { + return learningAuditAction{"learning.question_reorder", "learning_test", testID} + } + if len(parts) == 4 { + switch method { + case http.MethodPut: + return learningAuditAction{"learning.question_update", "learning_question", parts[3]} + case http.MethodDelete: + return learningAuditAction{"learning.question_delete", "learning_question", parts[3]} + } + } + return learningAuditAction{} +} + +func matchAttemptAuditAction(method string, parts []string) learningAuditAction { + if method == http.MethodPost && len(parts) == 3 && parts[2] == "submit" { + return learningAuditAction{"learning.attempt_submit", "learning_attempt", parts[1]} + } + return learningAuditAction{} +} + +func matchCourseAuditAction(method string, parts []string) learningAuditAction { + if method == http.MethodPost && len(parts) == 1 { + return learningAuditAction{"learning.course_create", "learning_course", ""} + } + if len(parts) < 2 { + return learningAuditAction{} + } + courseID := parts[1] + if len(parts) == 2 { + switch method { + case http.MethodPatch: + return learningAuditAction{"learning.course_update", "learning_course", courseID} + case http.MethodDelete: + return learningAuditAction{"learning.course_delete", "learning_course", courseID} + } + } + if method == http.MethodPost && len(parts) == 3 && parts[2] == "lessons" { + return learningAuditAction{"learning.lesson_create", "learning_course", courseID} + } + if method == http.MethodPost && len(parts) == 4 && parts[2] == "lessons" && parts[3] == "reorder" { + return learningAuditAction{"learning.lesson_reorder", "learning_course", courseID} + } + return learningAuditAction{} +} + +func matchLessonAuditAction(method string, parts []string) learningAuditAction { + if len(parts) < 2 { + return learningAuditAction{} + } + lessonID := parts[1] + if len(parts) == 2 { + switch method { + case http.MethodPatch: + return learningAuditAction{"learning.lesson_update", "learning_lesson", lessonID} + case http.MethodDelete: + return learningAuditAction{"learning.lesson_delete", "learning_lesson", lessonID} + } + } + if len(parts) == 3 && parts[2] == "video" { + switch method { + case http.MethodPost: + return learningAuditAction{"learning.lesson_video_upload", "learning_lesson", lessonID} + case http.MethodDelete: + return learningAuditAction{"learning.lesson_video_delete", "learning_lesson", lessonID} + } + } + return learningAuditAction{} +} + +func matchAccessAuditAction(method string, parts []string) learningAuditAction { + entityID := strings.Join(parts, "/") + if method == http.MethodPost && len(parts) == 3 { + return learningAuditAction{"learning.access_grant_create", "learning_access", entityID} + } + if method == http.MethodDelete && len(parts) == 5 && parts[3] == "grants" { + return learningAuditAction{"learning.access_grant_delete", "learning_access_grant", parts[4]} + } + return learningAuditAction{} +} + +func matchPublicTokenAuditAction(method string, parts []string) learningAuditAction { + if method == http.MethodPost && len(parts) == 1 { + return learningAuditAction{"learning.public_token_create", "learning_public_token", ""} + } + if method == http.MethodDelete && len(parts) == 2 { + return learningAuditAction{"learning.public_token_revoke", "learning_public_token", parts[1]} + } + return learningAuditAction{} +} + +func extractAuditEntityID(body []byte) string { + if len(body) == 0 { + return "" + } + var payload struct { + ID any `json:"id"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + switch id := payload.ID.(type) { + case string: + return id + case float64: + return strconv.FormatInt(int64(id), 10) + default: + return "" + } +} diff --git a/internal/handler/audit_test.go b/internal/handler/audit_test.go new file mode 100644 index 0000000..791ed86 --- /dev/null +++ b/internal/handler/audit_test.go @@ -0,0 +1,117 @@ +package handler + +import ( + "net/http" + "testing" +) + +func TestMatchLearningAuditAction(t *testing.T) { + tests := []struct { + name string + method string + path string + action string + entityType string + entityID string + }{ + { + name: "test create", + method: http.MethodPost, + path: "/api/tests", + action: "learning.test_create", + entityType: "learning_test", + }, + { + name: "question reorder", + method: http.MethodPost, + path: "/api/tests/7/questions/reorder", + action: "learning.question_reorder", + entityType: "learning_test", + entityID: "7", + }, + { + name: "question update", + method: http.MethodPut, + path: "/api/tests/7/questions/9", + action: "learning.question_update", + entityType: "learning_question", + entityID: "9", + }, + { + name: "attempt submit", + method: http.MethodPost, + path: "/api/attempts/11/submit", + action: "learning.attempt_submit", + entityType: "learning_attempt", + entityID: "11", + }, + { + name: "course update", + method: http.MethodPatch, + path: "/api/courses/5", + action: "learning.course_update", + entityType: "learning_course", + entityID: "5", + }, + { + name: "lesson create", + method: http.MethodPost, + path: "/api/courses/5/lessons", + action: "learning.lesson_create", + entityType: "learning_course", + entityID: "5", + }, + { + name: "lesson video upload", + method: http.MethodPost, + path: "/api/lessons/3/video", + action: "learning.lesson_video_upload", + entityType: "learning_lesson", + entityID: "3", + }, + { + name: "access grant create", + method: http.MethodPost, + path: "/api/access/course/5", + action: "learning.access_grant_create", + entityType: "learning_access", + entityID: "access/course/5", + }, + { + name: "public token revoke", + method: http.MethodDelete, + path: "/api/public-tokens/13", + action: "learning.public_token_revoke", + entityType: "learning_public_token", + entityID: "13", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchLearningAuditAction(tt.method, tt.path) + if got.Action != tt.action || got.EntityType != tt.entityType || got.EntityID != tt.entityID { + t.Fatalf("unexpected action: got %#v, want action=%q entityType=%q entityID=%q", got, tt.action, tt.entityType, tt.entityID) + } + }) + } +} + +func TestMatchLearningAuditActionUnknown(t *testing.T) { + got := matchLearningAuditAction(http.MethodPost, "/public/learning/tokens/token/resolve") + if got.Action != "" { + t.Fatalf("expected no audit action, got %#v", got) + } +} + +func TestExtractAuditEntityID(t *testing.T) { + if got := extractAuditEntityID([]byte(`{"id":42}`)); got != "42" { + t.Fatalf("numeric id = %q, want 42", got) + } + if got := extractAuditEntityID([]byte(`{"id":"abc"}`)); got != "abc" { + t.Fatalf("string id = %q, want abc", got) + } + if got := extractAuditEntityID([]byte(`{"title":"missing"}`)); got != "" { + t.Fatalf("missing id = %q, want empty", got) + } +}