Netpulse_SasS/server/internal/httpapi/principal.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

194 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"errors"
"net"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/store"
)
// Principal — хто саме робить запит.
//
// Два джерела автентифікації зводяться до одного типу: людина з
// access-токеном і машинний токен інтеграції. Обробникам байдуже, хто
// перед ними, — вони питають лише «чи можна». Інакше кожен обробник мав
// би дві гілки, і будь-яке нове право доводилось би додавати двічі.
type Principal struct {
TenantID string
// Порожній для машинного токена.
UserID string
Username string
Email string
// Порожній для людини.
TokenID string
TokenName string
perms map[string]bool
// Машинний токен без scopes успадковує повний доступ тенанта:
// так поводяться ключі, які власник створює сам для себе.
unrestricted bool
// scope — які саме пристрої видно. Права відповідають на питання
// «що вільно робити», scope — «над чим». Два незалежні виміри:
// інженер над однією філією та інженер над усією мережею мають
// однакову роль і різний доступ.
scope store.Scope
}
// Scope — обмеження видимості для вибірок.
func (p *Principal) Scope() store.Scope {
if p == nil {
return store.Scope{Unrestricted: true}
}
return p.scope
}
func (p *Principal) Can(perm string) bool {
if p == nil {
return false
}
if p.unrestricted {
return true
}
return p.perms[perm] || p.perms["*"]
}
// Permissions — перелік прав для /api/v1/me.
func (p *Principal) Permissions() []string {
if p.unrestricted {
return []string{"*"}
}
out := make([]string, 0, len(p.perms))
for k := range p.perms {
out = append(out, k)
}
return out
}
func (p *Principal) IsUser() bool { return p.UserID != "" }
// ---------------------------------------------------------------------
// authenticated обгортає обробник перевіркою особи.
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *Principal)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен")
return
}
p, err := s.resolvePrincipal(r, raw)
switch {
case errors.Is(err, auth.ErrTokenExpired):
// Окремий код: клієнт має піти по refresh, а не викидати
// користувача на сторінку входу.
writeError(w, http.StatusUnauthorized, "token_expired", "строк дії токена минув")
return
case err != nil:
writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний")
return
}
next(w, r, p)
})
}
func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, error) {
ctx := r.Context()
// JWT має рівно дві крапки; машинні токени — жодної. Розрізняти за
// формою надійніше, ніж за префіксом: префікс задає людина.
if strings.Count(raw, ".") == 2 {
if s.signer == nil {
return nil, errors.New("підпис токенів не налаштовано")
}
claims, err := s.signer.Verify(raw)
if err != nil {
return nil, err
}
perms, err := s.store.UserPermissions(ctx, claims.UserID, claims.TenantID)
if err != nil {
return nil, err
}
// Порожній набір означає, що членство відкликали вже після
// видачі токена. Пускати такого не можна.
if len(perms) == 0 {
return nil, errors.New("немає прав у цьому тенанті")
}
p := &Principal{
TenantID: claims.TenantID,
UserID: claims.UserID,
Email: claims.Email,
perms: make(map[string]bool, len(perms)),
}
for _, k := range perms {
p.perms[k] = true
}
// Обмеження за групами читається на кожному запиті разом із
// правами — з тієї самої причини: зміна складу групи має діяти
// одразу, а не після протермінування токена.
sc, err := s.store.LoadScope(ctx, claims.TenantID, claims.UserID)
if err != nil {
return nil, err
}
p.scope = sc
return p, nil
}
tok, err := s.store.AuthenticateAPIToken(ctx, raw)
if err != nil {
return nil, err
}
p := &Principal{
TenantID: tok.TenantID,
TokenID: tok.ID,
TokenName: tok.Name,
perms: make(map[string]bool, len(tok.Scopes)),
}
if len(tok.Scopes) == 0 {
p.unrestricted = true
}
for _, k := range tok.Scopes {
p.perms[k] = true
}
// Машинний токен групами не обмежений: у нього немає членства, а
// його межі задає власний scopes.
p.scope = store.Scope{Unrestricted: true}
return p, nil
}
// requirePerm — коротка форма перевірки з однаковою відповіддю.
func requirePerm(w http.ResponseWriter, p *Principal, perm string) bool {
if p.Can(perm) {
return true
}
writeError(w, http.StatusForbidden, "forbidden", "немає права "+perm)
return false
}
// clientIP дістає адресу з урахуванням зворотного проксі.
//
// X-Forwarded-For береться лише перший елемент: решту дописують
// проміжні проксі, і довіряти їм не можна. Сам заголовок має сенс
// тільки якщо перед сервісом справді стоїть довірений проксі.
func clientIP(r *http.Request) net.IP {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return net.ParseIP(host)
}