package httpapi import ( "net/http" "strconv" "strings" "time" "github.com/netpulse/netpulse/server/internal/store" ) // «Історія входів»: читання core.login_attempts, і тільки читання. // // Рідна сестра журналу аудиту: ті самі два GET (перелік і довідники), // той самий курсор замість offset, той самий закріплений період і той // самий блок про межі показаного. Різниця одна, і вона в шарі сховища: // у core.login_attempts немає tenant_id, тож ізоляція кабінетів там // зроблена руками — див. шапку store/login_attempts.go. // // ПРАВО ТЕ САМЕ, ЩО В ЖУРНАЛУ АУДИТУ, і це не економія на переліку // прав. Обидві сторінки відповідають на одне питання — «хто і що робив // з нашим доступом», — і роздати їх різним людям означало б, що один // бачить «видалив конфіги о 14:02», а другий «увійшов о 14:01 з чужої // адреси». Розслідування — це рівно поєднання цих двох рядків. // audit:read і так є лише у власника й адміністратора. // // Ручки, яка стирає рядок, тут немає й не буде — з тієї ж причини, що в // audit.go: історія входів, яку можна підчистити, не доводить нічого. const loginsPerm = auditPerm // Стеля глибини вибірки — така сама, як у журналі. Ширшу межу тут // ставити нема сенсу: спроби входу типово живуть 180 днів (0012), тобто // глибше за півроку в таблиці порожньо в будь-якому разі. const loginsMaxRange = auditMaxRange func (s *Server) handleListLoginAttempts(w http.ResponseWriter, r *http.Request, p *Principal) { if !requirePerm(w, p, loginsPerm) { return } q := r.URL.Query() f := store.LoginAttemptFilter{ Reasons: splitParam(q, "reason"), UserIDs: splitParam(q, "user"), IP: strings.TrimSpace(q.Get("ip")), Query: strings.TrimSpace(q.Get("q")), Cursor: q.Get("cursor"), } // Результат — три стани, а не прапорець. Значення, якого немає в // переліку, ігнорується мовчки: сторінка кладе в адресу лише свої // три, а чуже слово в чужому посиланні має дати повний перелік, а // не відмову. switch v := q.Get("outcome"); v { case "failed", "success": f.Outcome = v } var err error if f.From, err = parseTimeParam(q.Get("from")); err != nil { writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «від»") return } if f.To, err = parseTimeParam(q.Get("to")); err != nil { writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «до»") return } // Верхня межа за замовчуванням — зараз, а не «без межі»: та сама // причина, що в журналі аудиту. Вхід, який стався між першою // сторінкою й наступною, інакше зсунув би всю вибірку на рядок. if f.To.IsZero() { f.To = time.Now().UTC() } if f.From.IsZero() { // Типовий період коротший, ніж у журналі (30 днів), і це не // різнобій. Сюди приходять із питанням «що відбувається зараз», // а не «що було в березні»: невдалі спроби цінні свіжими, і // сім днів — це вікно, у якому їх ще можна з кимось звірити. f.From = f.To.Add(-7 * 24 * time.Hour) } if !f.From.Before(f.To) { writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець") return } if f.To.Sub(f.From) > loginsMaxRange { writeError(w, http.StatusBadRequest, "invalid", "період не більший за рік — звузьте діапазон або скористайтесь фільтрами") return } if n, err := strconv.Atoi(q.Get("limit")); err == nil { f.Limit = n } // Scope тут не застосовується — з тієї ж причини, що в журналі // аудиту: він відповідає на «які ХОСТИ видно», а спроба входу не // про хост. Межа видимості тут інша й жорсткіша — членство в // кабінеті, і вона в сховищі. page, err := s.store.ListLoginAttempts(r.Context(), p.TenantID, f) if err != nil { s.writeStoreError(w, "історія входів", err) return } writeJSON(w, http.StatusOK, page) } // handleLoginAttemptsMeta — усе, що потрібно сторінці на відкритті, // одним викликом. Одним, а не трьома: це стан сторінки на момент // відкриття, і зібраний із трьох відповідей у різні секунди він нічого // не виграє, зате додає два стани завантаження на екран. func (s *Server) handleLoginAttemptsMeta(w http.ResponseWriter, r *http.Request, p *Principal) { if !requirePerm(w, p, loginsPerm) { return } actors, err := s.store.LoginActors(r.Context(), p.TenantID) if err != nil { s.writeStoreError(w, "перелік людей в історії входів", err) return } bounds, err := s.store.LoginAttemptsBounds(r.Context(), p.TenantID) if err != nil { s.writeStoreError(w, "межі історії входів", err) return } writeJSON(w, http.StatusOK, map[string]any{ "reasons": store.LoginReasons(), "actors": actors, "bounds": bounds, // Чого сторінка не бачить. Не оздоблення: саме сюди приходять // із питанням «нас ламають?», і мовчазна порожнеча читалась би // як «ні». "blind_spots": store.LoginBlindSpots(), "max_range_days": int(loginsMaxRange / (24 * time.Hour)), }) }