1package web
2
3import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/netip"
8 "net/url"
9 "regexp"
10 "slices"
11 "strconv"
12 "strings"
13 "time"
14 "unicode/utf8"
15
16 "tug/internal/platform/auditcatalog"
17 transporthttp "tug/internal/transport/http"
18 "tug/internal/transport/web/text"
19)
20
21const (
22 AdminAuditPerPage = 50
23 adminAuditValueMax = 32
24 adminAuditSubjectMax = 64
25 adminAuditDetailMax = 6
26 adminAuditTime = "2006-01-02T15:04"
27)
28
29var (
30 reAuditName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
31 reAuditCursor = regexp.MustCompile(`^([0-9]{1,19})\.([1-9][0-9]{0,18})$`)
32 auditParams = []string{"actor", "action", "subject", "address", "from", "to", "older", "newer"}
33)
34
35type AdminAuditCursor struct {
36 At time.Time
37 ID int64
38}
39
40type AdminAuditQuery struct {
41 Actor string
42 Action string
43 Subject string
44 Address netip.Addr
45 From *time.Time
46 To *time.Time
47 Older *AdminAuditCursor
48 Newer *AdminAuditCursor
49}
50
51type AdminAuditRow struct {
52 ID int64
53 At time.Time
54 Actor string
55 Action string
56 Subject string
57 Address string
58 Detail string
59}
60
61type AdminAuditPage struct {
62 Rows []AdminAuditRow
63 Newer bool
64 Older bool
65 Retention time.Duration
66}
67
68type adminAuditForm struct {
69 Actor, Action, Subject, Address, From, To string
70}
71
72type adminAuditRowView struct {
73 At string
74 Actor Ident
75 Sentence text.Key
76 Action Ident
77 Subject Ident
78 Address Ident
79 Detail []Ident
80 More int
81}
82
83type adminAuditField struct {
84 Name, Value string
85}
86
87type adminAuditPager struct {
88 Fields []adminAuditField
89}
90
91type adminAuditOption struct {
92 Value string
93 Selected bool
94}
95
96type adminAuditData struct {
97 Viewer Ident
98 Filter adminAuditForm
99 Filtered bool
100 Actions []adminAuditOption
101 Rows []adminAuditRowView
102 Newer, Older *adminAuditPager
103 RetentionCount int
104 RetentionUnit text.Key
105}
106
107func parseAuditCursor(v string) (*AdminAuditCursor, bool) {
108 m := reAuditCursor.FindStringSubmatch(v)
109 if m == nil {
110 return nil, false
111 }
112 at, err := strconv.ParseInt(m[1], 10, 64)
113 if err != nil {
114 return nil, false
115 }
116 id, err := strconv.ParseInt(m[2], 10, 64)
117 if err != nil {
118 return nil, false
119 }
120 return &AdminAuditCursor{At: time.UnixMicro(at).UTC(), ID: id}, true
121}
122
123func parseAuditTime(v string) (*time.Time, bool) {
124 t, err := time.ParseInLocation(adminAuditTime, v, time.UTC)
125 if err != nil || t.Format(adminAuditTime) != v {
126 return nil, false
127 }
128 return &t, true
129}
130
131func parseAuditQuery(values url.Values) (AdminAuditQuery, adminAuditForm, bool) {
132 var q AdminAuditQuery
133 var f adminAuditForm
134 for k, vs := range values {
135 if !slices.Contains(auditParams, k) || len(vs) != 1 {
136 return q, f, false
137 }
138 }
139 get := values.Get
140 if v := get("actor"); v != "" {
141 if v != "system" && !reAdminID.MatchString(v) && !reAuditName.MatchString(v) {
142 return q, f, false
143 }
144 q.Actor, f.Actor = v, v
145 }
146 if v := get("action"); v != "" {
147 if _, ok := auditcatalog.Lookup(v); !ok {
148 return q, f, false
149 }
150 q.Action, f.Action = v, v
151 }
152 if v := get("subject"); v != "" {
153 if !reAdminID.MatchString(v) && !reSessionID.MatchString(v) && !reAuditName.MatchString(v) {
154 return q, f, false
155 }
156 q.Subject, f.Subject = v, v
157 }
158 if v := get("address"); v != "" {
159 a, err := netip.ParseAddr(v)
160 if err != nil || a.Zone() != "" {
161 return q, f, false
162 }
163 q.Address = a.Unmap()
164 f.Address = q.Address.String()
165 }
166 for _, t := range []struct {
167 name string
168 dst **time.Time
169 echo *string
170 }{{"from", &q.From, &f.From}, {"to", &q.To, &f.To}} {
171 if v := get(t.name); v != "" {
172 at, ok := parseAuditTime(v)
173 if !ok {
174 return q, f, false
175 }
176 *t.dst, *t.echo = at, v
177 }
178 }
179 older, newer := get("older"), get("newer")
180 if older != "" && newer != "" {
181 return q, f, false
182 }
183 for _, c := range []struct {
184 value string
185 dst **AdminAuditCursor
186 }{{older, &q.Older}, {newer, &q.Newer}} {
187 if c.value != "" {
188 cur, ok := parseAuditCursor(c.value)
189 if !ok {
190 return q, f, false
191 }
192 *c.dst = cur
193 }
194 }
195 return q, f, true
196}
197
198func auditClip(s string) string {
199 return clipTo(s, adminAuditValueMax)
200}
201
202func clipTo(s string, max int) string {
203 s = strings.ToValidUTF8(s, "")
204 if len(s) <= max {
205 return s
206 }
207 cut := max - len("…")
208 for cut > 0 && !utf8.RuneStart(s[cut]) {
209 cut--
210 }
211 return s[:cut] + "…"
212}
213
214func auditValue(v any) string {
215 switch v := v.(type) {
216 case string:
217 return v
218 case []any:
219 out := make([]string, 0, len(v))
220 for _, e := range v {
221 out = append(out, auditValue(e))
222 }
223 return strings.Join(out, " ")
224 case nil:
225 return "null"
226 }
227 var b bytes.Buffer
228 json.NewEncoder(&b).Encode(v)
229 return strings.TrimSpace(b.String())
230}
231
232func auditDetail(raw string) []Ident {
233 dec := json.NewDecoder(strings.NewReader(raw))
234 dec.UseNumber()
235 var fields map[string]any
236 if err := dec.Decode(&fields); err != nil || fields == nil {
237 if strings.TrimSpace(raw) == "" {
238 return nil
239 }
240 return []Ident{Ident(auditClip(raw))}
241 }
242 keys := make([]string, 0, len(fields))
243 for k := range fields {
244 keys = append(keys, k)
245 }
246 slices.Sort(keys)
247 var parts []Ident
248 for _, k := range keys {
249 parts = append(parts, Ident(auditClip(k)+"="+auditClip(auditValue(fields[k]))))
250 }
251 return parts
252}
253
254func actionSentence(action string) text.Key {
255 e, ok := auditcatalog.Lookup(action)
256 if !ok {
257 return ""
258 }
259 if _, ok := text.Lookup(text.Key(e.Sentence)); !ok {
260 return ""
261 }
262 return text.Key(e.Sentence)
263}
264
265func auditCursorValue(row AdminAuditRow) string {
266 return strconv.FormatInt(row.At.UnixMicro(), 10) + "." + strconv.FormatInt(row.ID, 10)
267}
268
269func auditPager(f adminAuditForm, dir string, row AdminAuditRow) *adminAuditPager {
270 p := &adminAuditPager{}
271 for _, field := range []adminAuditField{{"actor", f.Actor}, {"action", f.Action}, {"subject", f.Subject}, {"address", f.Address}, {"from", f.From}, {"to", f.To}} {
272 if field.Value != "" {
273 p.Fields = append(p.Fields, field)
274 }
275 }
276 p.Fields = append(p.Fields, adminAuditField{dir, auditCursorValue(row)})
277 return p
278}
279
280func retentionShown(d time.Duration) (int, text.Key) {
281 switch {
282 case d%(24*time.Hour) != 0:
283 return int(d / time.Hour), text.AdminAuditUnitHours
284 case d == 24*time.Hour:
285 return 1, text.AdminAuditUnitDay
286 }
287 return int(d / (24 * time.Hour)), text.AdminAuditUnitDays
288}
289
290func auditPageData(viewer Ident, f adminAuditForm, p AdminAuditPage) adminAuditData {
291 d := adminAuditData{Viewer: viewer, Filter: f, Filtered: f != adminAuditForm{}}
292 d.RetentionCount, d.RetentionUnit = retentionShown(p.Retention)
293 for _, e := range auditcatalog.Entries() {
294 d.Actions = append(d.Actions, adminAuditOption{Value: e.Name, Selected: e.Name == f.Action})
295 }
296 rows := p.Rows[:min(len(p.Rows), AdminAuditPerPage)]
297 for _, e := range rows {
298 detail := auditDetail(e.Detail)
299 more := max(0, len(detail)-adminAuditDetailMax)
300 d.Rows = append(d.Rows, adminAuditRowView{
301 At: e.At.UTC().Format("2006-01-02 15:04:05"),
302 Actor: Ident(e.Actor),
303 Sentence: actionSentence(e.Action),
304 Action: Ident(e.Action),
305 Subject: Ident(clipTo(e.Subject, adminAuditSubjectMax)),
306 Address: Ident(e.Address),
307 Detail: detail[:len(detail)-more],
308 More: more,
309 })
310 }
311 if len(rows) > 0 && p.Newer {
312 d.Newer = auditPager(f, "newer", rows[0])
313 }
314 if len(rows) > 0 && (p.Older || len(p.Rows) > len(rows)) {
315 d.Older = auditPager(f, "older", rows[len(rows)-1])
316 }
317 return d
318}
319
320func (a *admin) audit(w http.ResponseWriter, r *http.Request) {
321 p, ok := a.kit.principal(w, r)
322 if !ok {
323 return
324 }
325 values, err := transporthttp.Query(r)
326 if err != nil {
327 a.badOp(w, r)
328 return
329 }
330 q, form, ok := parseAuditQuery(values)
331 if !ok {
332 a.badOp(w, r)
333 return
334 }
335 page, err := a.b.Audit(r.Context(), q)
336 if err != nil {
337 a.failed(w, r, "audit", err)
338 return
339 }
340 a.render(w, r, http.StatusOK, "admin-audit-log", auditPageData(p.Username, form, page))
341}