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}