Raw
  1package admin
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"errors"
  7	"net/netip"
  8	"regexp"
  9	"time"
 10
 11	"tug/internal/platform/auditcatalog"
 12	"tug/internal/storage"
 13)
 14
 15const (
 16	AuditPerPage = 50
 17	foldWindow   = 2 * RecentEntries
 18)
 19
 20var (
 21	reAuditID      = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
 22	reAuditSession = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
 23)
 24
 25type AuditCursor struct {
 26	At time.Time
 27	ID int64
 28}
 29
 30type AuditQuery struct {
 31	Actor   string
 32	Action  string
 33	Subject string
 34	Address netip.Addr
 35	From    *time.Time
 36	To      *time.Time
 37	Older   *AuditCursor
 38	Newer   *AuditCursor
 39}
 40
 41type AuditRow struct {
 42	ID      int64
 43	At      time.Time
 44	Actor   string
 45	Action  string
 46	Subject string
 47	Address string
 48	Detail  string
 49}
 50
 51type AuditPage struct {
 52	Rows      []AuditRow
 53	Newer     bool
 54	Older     bool
 55	Retention time.Duration
 56}
 57
 58func (s *Service) Audit(ctx context.Context, q AuditQuery) (AuditPage, error) {
 59	out := AuditPage{Retention: s.retention}
 60	actor, ok, err := s.auditActor(ctx, q.Actor)
 61	if err != nil || !ok {
 62		return out, err
 63	}
 64	subject, err := s.auditSubject(ctx, q.Subject)
 65	if err != nil {
 66		return out, err
 67	}
 68	filter := storage.AuditPageFilter{Actor: actor, Action: q.Action, Subject: subject, Address: q.Address, After: q.From, Before: q.To, Limit: AuditPerPage}
 69	if q.Older != nil {
 70		filter.Older = &storage.AuditCursor{OccurredAt: q.Older.At, ID: q.Older.ID}
 71	}
 72	if q.Newer != nil {
 73		filter.Newer = &storage.AuditCursor{OccurredAt: q.Newer.At, ID: q.Newer.ID}
 74	}
 75	page, err := s.store.ListAuditPage(ctx, filter)
 76	if err != nil {
 77		return out, failed("read the audit page", err)
 78	}
 79	names := s.resolver(ctx)
 80	for _, e := range page.Events {
 81		actor, err := names.actor(e.Actor)
 82		if err != nil {
 83			return out, err
 84		}
 85		subject, err := names.subjectOf(e)
 86		if err != nil {
 87			return out, err
 88		}
 89		row := AuditRow{ID: e.ID, At: e.OccurredAt, Actor: actor, Action: e.Action, Subject: subject, Detail: e.Detail}
 90		if e.Address.IsValid() {
 91			row.Address = e.Address.String()
 92		}
 93		out.Rows = append(out.Rows, row)
 94	}
 95	out.Older, out.Newer = page.Older != nil, page.Newer != nil
 96	return out, nil
 97}
 98
 99func (s *Service) auditActor(ctx context.Context, name string) (string, bool, error) {
100	if name == "" || name == "system" || reAuditID.MatchString(name) {
101		return name, true, nil
102	}
103	u, err := s.store.UserByUsername(ctx, name)
104	switch {
105	case errors.Is(err, storage.ErrNotFound):
106		return "", false, nil
107	case err != nil:
108		return "", false, failed("resolve the audit actor", err)
109	}
110	return u.ID, true, nil
111}
112
113func (s *Service) auditSubject(ctx context.Context, name string) (string, error) {
114	if name == "" || reAuditID.MatchString(name) || reAuditSession.MatchString(name) {
115		return name, nil
116	}
117	u, err := s.store.UserByUsername(ctx, name)
118	switch {
119	case err == nil:
120		return u.ID, nil
121	case !errors.Is(err, storage.ErrNotFound):
122		return "", failed("resolve the audit subject", err)
123	}
124	g, err := s.store.GroupByName(ctx, name)
125	switch {
126	case err == nil:
127		return g.ID, nil
128	case !errors.Is(err, storage.ErrNotFound):
129		return "", failed("resolve the audit subject", err)
130	}
131	return name, nil
132}
133
134func detailString(raw, key string) string {
135	var fields map[string]any
136	if json.Unmarshal([]byte(raw), &fields) != nil {
137		return ""
138	}
139	v, _ := fields[key].(string)
140	return v
141}
142
143func (r *resolver) subjectOf(e storage.AuditEvent) (string, error) {
144	entry, ok := auditcatalog.Lookup(e.Action)
145	if !ok {
146		return r.subject(e.Subject)
147	}
148	for _, kind := range entry.Subjects {
149		switch kind {
150		case auditcatalog.SubjectSession:
151			return detailString(e.Detail, "host"), nil
152		case auditcatalog.SubjectUser:
153			if name, found, err := r.username(e.Subject); err != nil || found {
154				return name, err
155			}
156		case auditcatalog.SubjectGroup:
157			if name, found, err := r.groupName(e.Subject); err != nil || found {
158				return name, err
159			}
160		}
161	}
162	return e.Subject, nil
163}
164
165type foldKey struct {
166	subject string
167	at      int64
168}
169
170func foldSessions(events []storage.AuditEvent) ([]storage.AuditEvent, map[int64]bool) {
171	opens := map[foldKey]int64{}
172	for _, e := range events {
173		if e.Action == "session.open" && detailString(e.Detail, "via") == "oidc" {
174			opens[foldKey{e.Subject, e.OccurredAt.UnixMicro()}] = e.ID
175		}
176	}
177	folded := map[int64]bool{}
178	fresh := map[int64]bool{}
179	for _, e := range events {
180		if e.Action != "session.signin" {
181			continue
182		}
183		if id, ok := opens[foldKey{e.Subject, e.OccurredAt.UnixMicro()}]; ok && !folded[id] {
184			folded[id] = true
185			fresh[e.ID] = true
186		}
187	}
188	out := make([]storage.AuditEvent, 0, len(events))
189	for _, e := range events {
190		if !folded[e.ID] {
191			out = append(out, e)
192		}
193	}
194	return out, fresh
195}
196
197func (r *resolver) recent(events []storage.AuditEvent) ([]AuditEntry, error) {
198	events, fresh := foldSessions(events)
199	entries, err := r.entries(events[:min(len(events), RecentEntries)])
200	if err != nil {
201		return nil, err
202	}
203	for i := range entries {
204		entries[i].NewSession = fresh[events[i].ID]
205	}
206	return entries, nil
207}