package adminapi import ( "context" "encoding/json" "net/http" "strconv" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // The actions the audit log records, one per kind of change the admin API // can make. They are part of the API: the panel filters on them. const ( auditFederationSwitch = "federation.switch" auditFederationLists = "federation.lists" auditFederationReset = "federation.lists_reset" auditDestinationReset = "federation.destination_reset" auditRegistrationSwitch = "registration.switch" auditUserCreate = "user.create" auditUserAdmin = "user.admin" auditUserDeactivate = "user.deactivate" auditUserReactivate = "user.reactivate" auditUserPassword = "user.password" auditUserDeviceDelete = "user.device_delete" auditUserMediaQuota = "user.media_quota" auditRoomPurge = "room.purge" auditRoomForceLeave = "room.force_leave" auditTokenCreate = "registration_token.create" auditTokenUpdate = "registration_token.update" auditTokenDelete = "registration_token.delete" auditServerNotice = "server_notice.send" ) // audit records a change made through the admin API, after it has been // made. // // A failure to write the entry is logged and does not fail the request: // the change has happened, and answering 500 for something that worked // would have the panel retry it. The log line carries the same // information, so nothing is lost but the convenience of the table. func (a *AdminAPI) audit(ctx context.Context, auth userapi.Authenticated, action, target string, details map[string]any) { raw := []byte("{}") if len(details) > 0 { var err error if raw, err = json.Marshal(details); err != nil { raw = []byte("{}") } } a.log.Info("admin action", "action", action, "target", target, "by", auth.UserID, "details", string(raw)) if a.adminStore == nil { return } if _, err := a.adminStore.AppendAudit(context.WithoutCancel(ctx), storage.AuditEntry{ TSMS: a.now().UnixMilli(), Actor: auth.UserID, Action: action, Target: target, Details: raw, }); err != nil { a.log.Error("could not write the audit log", "action", action, "target", target, "error", err) } } // auditEntry is one entry as the API shows it. type auditEntry struct { ID int64 `json:"id"` TSMS int64 `json:"ts_ms"` Actor string `json:"actor"` Action string `json:"action"` Target string `json:"target,omitempty"` Details json.RawMessage `json:"details"` } type auditResponse struct { Entries []auditEntry `json:"entries"` // NextBefore is the ?before= of the next page, absent on the last. NextBefore int64 `json:"next_before,omitempty"` } // defaultAuditPageLimit is what a request without ?limit= gets, and // maxAuditPageLimit the most it may ask for. const ( defaultAuditPageLimit = 50 maxAuditPageLimit = 1000 ) // handleGetAuditLog implements GET /_warp/admin/v1/audit_log. // // It pages backwards from the newest entry by ID rather than by offset: // the log grows at the top while an admin reads it, and an offset would // shift under them. func (a *AdminAPI) handleGetAuditLog(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { if a.adminStore == nil { return httputil.NotFound("This server keeps no audit log") } query := r.URL.Query() limit, err := intParam(query.Get("limit"), defaultAuditPageLimit) if err != nil || limit < 0 { return httputil.InvalidParam("limit must be a non-negative number") } // Clamped here rather than left to the store, so that "a full page" // below means the same thing the store does. if limit == 0 { limit = defaultAuditPageLimit } limit = min(limit, maxAuditPageLimit) var before int64 if raw := query.Get("before"); raw != "" { if before, err = strconv.ParseInt(raw, 10, 64); err != nil || before < 0 { return httputil.InvalidParam("before must be an entry ID") } } entries, err := a.adminStore.AuditLog(r.Context(), storage.AuditFilter{ Before: before, Limit: limit, Actor: query.Get("actor"), Action: query.Get("action"), }) if err != nil { return err } resp := auditResponse{Entries: make([]auditEntry, 0, len(entries))} for _, e := range entries { resp.Entries = append(resp.Entries, auditEntry{ ID: e.ID, TSMS: e.TSMS, Actor: e.Actor, Action: e.Action, Target: e.Target, Details: json.RawMessage(e.Details), }) } // A full page may have more behind it; a short one cannot. if len(entries) == limit { resp.NextBefore = entries[len(entries)-1].ID } httputil.NoCache(w) httputil.WriteJSON(w, http.StatusOK, resp) return nil }