Логін: - 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>
194 lines
6.5 KiB
Go
194 lines
6.5 KiB
Go
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)
|
||
}
|