Repository: dragonflyoss/dragonfly

Issue:
Support Audit Logs
### Feature request:
1. Add new table for Database.
2. Record all API operations as audit logs.
3. Display audit logs in Console.
<!-- Please describe the feature request and why you would like to have it -->

### Use case:

<!-- Please add a concrete use case to demonstrate how such a feature would add value for the user. -->

### UI Example:

<!-- If this is about a new command or command line options, please let us know how you would add it to UI (in the code block below). -->


Discussion:
@chlins: Resolved as https://github.com/dragonflyoss/dragonfly/pull/3958 merged.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aea3d5f4..3f06e3cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1306,7 +1306,7 @@
 - **deps:** bump github.com/prometheus/client_golang from 1.12.2 to 1.13.0 ([#1564](https://github.com/dragonflyoss/dragonfly/issues/1564))
 - **deps:** bump go.opentelemetry.io/otel/exporters/jaeger from 1.14.0 to 1.15.1 ([#2376](https://github.com/dragonflyoss/dragonfly/issues/2376))
 - **deps:** bump go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin from 0.32.0 to 0.34.0 ([#1547](https://github.com/dragonflyoss/dragonfly/issues/1547))
-- **deps:** bump github.com/sirupsen/logrus from 1.8.1 to 1.9.0 ([#1544](https://github.com/dragonflyoss/dragonfly/issues/1544))
+- **deps:** bump github.com/sirupsen/es, len(auditslogrus from 1.8.1 to 1.9.0 ([#1544](https://github.com/dragonflyoss/dragonfly/issues/1544))
 - **deps:** bump github.com/jarcoal/httpmock from 1.0.8 to 1.2.0 ([#1542](https://github.com/dragonflyoss/dragonfly/issues/1542))
 - **deps:** bump go.opentelemetry.io/otel/exporters/jaeger from 1.8.0 to 1.9.0 ([#1541](https://github.com/dragonflyoss/dragonfly/issues/1541))
 - **deps:** bump google.golang.org/grpc from 1.47.0 to 1.48.0 ([#1508](https://github.com/dragonflyoss/dragonfly/issues/1508))
diff --git a/api/manager/docs.go b/api/manager/docs.go
index 35f1e758..692d5148 100644
--- a/api/manager/docs.go
+++ b/api/manager/docs.go
@@ -241,6 +241,61 @@ const docTemplate = `{
                 }
             }
         },
+        "/api/v1/audits": {
+            "get": {
+                "description": "Get Audits",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Audit"
+                ],
+                "summary": "Get Audits",
+                "parameters": [
+                    {
+                        "type": "integer",
+                        "default": 0,
+                        "description": "current page",
+                        "name": "page",
+                        "in": "query",
+                        "required": true
+                    },
+                    {
+                        "maximum": 50,
+                        "minimum": 2,
+                        "type": "integer",
+                        "default": 10,
+                        "description": "return max item count, default 10, max 50",
+                        "name": "per_page",
+                        "in": "query",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/d7y_io_dragonfly_v2_manager_models.Audit"
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request"
+                    },
+                    "404": {
+                        "description": "Not Found"
+                    },
+                    "500": {
+                        "description": "Internal Server Error"
+                    }
+                }
+            }
+        },
         "/api/v1/buckets": {
             "get": {
                 "description": "Get Buckets",
@@ -4140,6 +4195,50 @@ const docTemplate = `{
                 }
             }
         },
+        "d7y_io_dragonfly_v2_manager_models.Audit": {
+            "type": "object",
+            "properties": {
+                "actor_name": {
+                    "type": "string"
+                },
+                "actor_type": {
+                    "type": "string"
+                },
+                "created_at": {
+                    "type": "string"
+                },
+                "detail": {
+                    "$ref": "#/definitions/d7y_io_dragonfly_v2_manager_models.JSONMap"
+                },
+                "event_type": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "integer"
+                },
+                "is_del": {
+                    "type": "integer"
+                },
+                "operated_at": {
+                    "type": "string"
+                },
+                "operation": {
+                    "type": "string"
+                },
+                "path": {
+                    "type": "string"
+                },
+                "state": {
+                    "type": "string"
+                },
+                "status_code": {
+                    "type": "integer"
+                },
+                "updated_at": {
+                    "type": "string"
+                }
+            }
+        },
         "d7y_io_dragonfly_v2_manager_models.Config": {
             "type": "object",
             "properties": {
diff --git a/api/manager/swagger.json b/api/manager/swagger.json
index 5eebf79a..60d9ba68 100644
--- a/api/manager/swagger.json
+++ b/api/manager/swagger.json
@@ -235,6 +235,61 @@
                 }
             }
         },
+        "/api/v1/audits": {
+            "get": {
+                "description": "Get Audits",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Audit"
+                ],
+                "summary": "Get Audits",
+                "parameters": [
+                    {
+                        "type": "integer",
+                        "default": 0,
+                        "description": "current page",
+                        "name": "page",
+                        "in": "query",
+                        "required": true
+                    },
+                    {
+                        "maximum": 50,
+                        "minimum": 2,
+                        "type": "integer",
+                        "default": 10,
+                        "description": "return max item count, default 10, max 50",
+                        "name": "per_page",
+                        "in": "query",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/d7y_io_dragonfly_v2_manager_models.Audit"
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request"
+                    },
+                    "404": {
+                        "description": "Not Found"
+                    },
+                    "500": {
+                        "description": "Internal Server Error"
+                    }
+                }
+            }
+        },
         "/api/v1/buckets": {
             "get": {
                 "description": "Get Buckets",
@@ -4134,6 +4189,50 @@
                 }
             }
         },
+        "d7y_io_dragonfly_v2_manager_models.Audit": {
+            "type": "object",
+            "properties": {
+                "actor_name": {
+                    "type": "string"
+                },
+                "actor_type": {
+                    "type": "string"
+                },
+                "created_at": {
+                    "type": "string"
+                },
+                "detail": {
+                    "$ref": "#/definitions/d7y_io_dragonfly_v2_manager_models.JSONMap"
+                },
+                "event_type": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "integer"
+                },
+                "is_del": {
+                    "type": "integer"
+                },
+                "operated_at": {
+                    "type": "string"
+                },
+                "operation": {
+                    "type": "string"
+                },
+                "path": {
+                    "type": "string"
+                },
+                "state": {
+                    "type": "string"
+                },
+                "status_code": {
+                    "type": "integer"
+                },
+                "updated_at": {
+                    "type": "string"
+                }
+            }
+        },
         "d7y_io_dragonfly_v2_manager_models.Config": {
             "type": "object",
             "properties": {
diff --git a/api/manager/swagger.yaml b/api/manager/swagger.yaml
index 8f00717e..0e0748c0 100644
--- a/api/manager/swagger.yaml
+++ b/api/manager/swagger.yaml
@@ -25,6 +25,35 @@ definitions:
       user_id:
         type: integer
     type: object
+  d7y_io_dragonfly_v2_manager_models.Audit:
+    properties:
+      actor_name:
+        type: string
+      actor_type:
+        type: string
+      created_at:
+        type: string
+      detail:
+        $ref: '#/definitions/d7y_io_dragonfly_v2_manager_models.JSONMap'
+      event_type:
+        type: string
+      id:
+        type: integer
+      is_del:
+        type: integer
+      operated_at:
+        type: string
+      operation:
+        type: string
+      path:
+        type: string
+      state:
+        type: string
+      status_code:
+        type: integer
+      updated_at:
+        type: string
+    type: object
   d7y_io_dragonfly_v2_manager_models.Config:
     properties:
       bio:
@@ -1526,6 +1555,44 @@ paths:
       summary: Update Application
       tags:
       - Application
+  /api/v1/audits:
+    get:
+      consumes:
+      - application/json
+      description: Get Audits
+      parameters:
+      - default: 0
+        description: current page
+        in: query
+        name: page
+        required: true
+        type: integer
+      - default: 10
+        description: return max item count, default 10, max 50
+        in: query
+        maximum: 50
+        minimum: 2
+        name: per_page
+        required: true
+        type: integer
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/d7y_io_dragonfly_v2_manager_models.Audit'
+            type: array
+        "400":
+          description: Bad Request
+        "404":
+          description: Not Found
+        "500":
+          description: Internal Server Error
+      summary: Get Audits
+      tags:
+      - Audit
   /api/v1/buckets:
     get:
       consumes:
diff --git a/build/images/base/Dockerfile b/build/images/base/Dockerfile
index 603bcd14..3b895aaf 100644
--- a/build/images/base/Dockerfile
+++ b/build/images/base/Dockerfile
@@ -11,14 +11,14 @@ RUN apt-get update \
 ENV PATH=/go/bin:/usr/local/go/bin:$PATH
 
 ENV GOPATH=/go
-ENV GOLANG_VERSION 1.23.0
-ENV GOLANG_DOWNLOAD_URL https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
+ENV GOLANG_VERSION 1.23.8
+ENV GOLANG_DOWNLOAD_URL https://go.dev/dl/go1.23.8.linux-amd64.tar.gz
 ENV GOLANG_DOWNLOAD_SHA256 000a5b1fca4f75895f78befeb2eecf10bfff3c428597f3f1e69133b63b911b02
 
 ARG GOPROXY
 
 RUN curl -fsSL "$GOLANG_DOWNLOAD_URL" -o golang.tar.gz \
-	&& echo "$GOLANG_DOWNLOAD_SHA256  golang.tar.gz" | sha256sum -c - \
-	&& tar -C /usr/local -xzf golang.tar.gz \
-	&& rm golang.tar.gz \
-	&& go install github.com/go-delve/delve/cmd/dlv@v1.7.0
+    && echo "$GOLANG_DOWNLOAD_SHA256  golang.tar.gz" | sha256sum -c - \
+    && tar -C /usr/local -xzf golang.tar.gz \
+    && rm golang.tar.gz \
+    && go install github.com/go-delve/delve/cmd/dlv@v1.7.0
diff --git a/build/images/dfdaemon/Dockerfile b/build/images/dfdaemon/Dockerfile
index ba7166f7..08ebfb39 100644
--- a/build/images/dfdaemon/Dockerfile
+++ b/build/images/dfdaemon/Dockerfile
@@ -1,6 +1,6 @@
 ARG BASE_IMAGE=alpine:3.20
 
-FROM golang:1.23.0-alpine3.20 AS builder
+FROM golang:1.23.8-alpine3.20 AS builder
 
 ARG GOPROXY
 ARG GOTAGS
diff --git a/build/images/manager/Dockerfile b/build/images/manager/Dockerfile
index 2eeb27d7..524b83be 100644
--- a/build/images/manager/Dockerfile
+++ b/build/images/manager/Dockerfile
@@ -12,7 +12,7 @@ COPY ./manager/console /build
 
 RUN yarn build
 
-FROM golang:1.23.0-alpine3.20 AS server-builder
+FROM golang:1.23.8-alpine3.20 AS server-builder
 
 ARG GOPROXY
 ARG GOTAGS
diff --git a/build/images/scheduler/Dockerfile b/build/images/scheduler/Dockerfile
index 0e225dae..5ce03f3a 100644
--- a/build/images/scheduler/Dockerfile
+++ b/build/images/scheduler/Dockerfile
@@ -1,6 +1,6 @@
 ARG BASE_IMAGE=alpine:3.20
 
-FROM golang:1.23.0-alpine3.20 AS builder
+FROM golang:1.23.8-alpine3.20 AS builder
 
 ARG GOPROXY
 ARG GOTAGS
diff --git a/build/plugin-builder/Dockerfile b/build/plugin-builder/Dockerfile
index 3c26e7c3..791775af 100644
--- a/build/plugin-builder/Dockerfile
+++ b/build/plugin-builder/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.23.0-alpine3.20
+FROM golang:1.23.8-alpine3.20
 
 ARG GOPROXY
 ARG GOTAGS
diff --git a/go.mod b/go.mod
index b8c46dde..562923bf 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module d7y.io/dragonfly/v2
 
-go 1.23.0
+go 1.23.8
 
 require (
 	cloud.google.com/go/storage v1.50.0
diff --git a/hack/build.sh b/hack/build.sh
index cb50312c..68e75151 100755
--- a/hack/build.sh
+++ b/hack/build.sh
@@ -11,7 +11,7 @@ SCHEDULER_BINARY_NAME=scheduler
 MANAGER_BINARY_NAME=manager
 
 PKG=d7y.io/dragonfly/v2
-BUILD_IMAGE=golang:1.23.0-alpine3.20
+BUILD_IMAGE=golang:1.23.8-alpine3.20
 
 VERSION=$(git rev-parse --short HEAD)
 BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
diff --git a/manager/database/database.go b/manager/database/database.go
index fd6ca6c7..d54b0e20 100644
--- a/manager/database/database.go
+++ b/manager/database/database.go
@@ -101,6 +101,7 @@ func New(cfg *config.Config) (*Database, error) {
 
 func migrate(db *gorm.DB) error {
 	return db.AutoMigrate(
+		&models.Audit{},
 		&models.Job{},
 		&models.SeedPeerCluster{},
 		&models.SeedPeer{},
diff --git a/manager/handlers/audit.go b/manager/handlers/audit.go
new file mode 100644
index 00000000..5cef9d6c
--- /dev/null
+++ b/manager/handlers/audit.go
@@ -0,0 +1,57 @@
+/*
+ *     Copyright 2025 The Dragonfly Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package handlers
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+
+	// nolint
+	_ "d7y.io/dragonfly/v2/manager/models"
+	"d7y.io/dragonfly/v2/manager/types"
+)
+
+// @Summary Get Audits
+// @Description Get Audits
+// @Tags Audit
+// @Accept json
+// @Produce json
+// @Param page query int true "current page" default(0)
+// @Param per_page query int true "return max item count, default 10, max 50" default(10) minimum(2) maximum(50)
+// @Success 200 {object} []models.Audit
+// @Failure 400
+// @Failure 404
+// @Failure 500
+// @Router /api/v1/audits [get]
+func (h *Handlers) GetAudits(ctx *gin.Context) {
+	var query types.GetAuditsQuery
+	if err := ctx.ShouldBindQuery(&query); err != nil {
+		ctx.JSON(http.StatusUnprocessableEntity, gin.H{"errors": err.Error()})
+		return
+	}
+
+	h.setPaginationDefault(&query.Page, &query.PerPage)
+	audits, count, err := h.service.GetAudits(ctx.Request.Context(), query)
+	if err != nil {
+		ctx.Error(err) //nolint: errcheck
+		return
+	}
+
+	h.setPaginationLinkHeader(ctx, query.Page, query.PerPage, int(count))
+	ctx.JSON(http.StatusOK, audits)
+}
diff --git a/manager/middlewares/audit.go b/manager/middlewares/audit.go
new file mode 100644
index 00000000..d95912f0
--- /dev/null
+++ b/manager/middlewares/audit.go
@@ -0,0 +1,79 @@
+/*
+ *     Copyright 2025 The Dragonfly Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package middlewares
+
+import (
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	logger "d7y.io/dragonfly/v2/internal/dflog"
+	"d7y.io/dragonfly/v2/manager/models"
+	"d7y.io/dragonfly/v2/manager/service"
+	"d7y.io/dragonfly/v2/manager/types"
+)
+
+func Audit(service service.Service) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		// Allow the request to proceed through the chain first.
+		c.Next()
+
+		actorType := models.ActorTypeUnknown
+		actorName := "unknown"
+		// Check if user information is available from previous auth middleware.
+		// Personal Access Token auth.
+		if rawPAT, ok := c.Get("pat"); ok {
+			if pat, ok := rawPAT.(*models.PersonalAccessToken); ok {
+				actorType = models.ActorTypePat
+				actorName = pat.Name
+			}
+		}
+
+		// JWT auth.
+		if rawID, ok := c.Get("id"); ok {
+			if id, ok := rawID.(float64); ok {
+				if user, err := service.GetUser(c.Request.Context(), uint(id)); err == nil {
+					actorType = models.ActorTypeUser
+					actorName = user.Name
+				} else {
+					logger.Errorf("failed to get user by id %d: %v", uint(id), err)
+				}
+			}
+		}
+
+		if err := service.AsyncCreateAudit(c.Request.Context(), &types.CreateAuditRequest{
+			ActorType:  actorType,
+			ActorName:  actorName,
+			EventType:  models.EventTypeAPI,
+			Operation:  c.Request.Method,
+			OperatedAt: time.Now(),
+			State:      httpStatusCodeToState(c.Writer.Status()),
+			Path:       c.Request.URL.Path,
+			StatusCode: c.Writer.Status(),
+		}); err != nil {
+			logger.Errorf("failed to create audit: %v", err)
+		}
+	}
+}
+
+// httpStatusCodeToState converts an HTTP status code to an audit state.
+func httpStatusCodeToState(statusCode int) string {
+	if statusCode >= 200 && statusCode < 300 {
+		return models.AuditStateSuccess
+	}
+	return models.AuditStateFailure
+}
diff --git a/manager/middlewares/personal_access_token.go b/manager/middlewares/personal_access_token.go
index 13fc7508..9dcde870 100644
--- a/manager/middlewares/personal_access_token.go
+++ b/manager/middlewares/personal_access_token.go
@@ -111,6 +111,7 @@ func PersonalAccessToken(gdb *gorm.DB) gin.HandlerFunc {
 			return
 		}
 
+		c.Set("pat", &token)
 		c.Next()
 	}
 }
diff --git a/manager/models/audit.go b/manager/models/audit.go
new file mode 100644
index 00000000..ce861868
--- /dev/null
+++ b/manager/models/audit.go
@@ -0,0 +1,72 @@
+/*
+ *     Copyright 2025 The Dragonfly Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package models
+
+import "time"
+
+const (
+	// ActorTypeUnknown represents an unknown actor type.
+	// e.g if user not authorized, the actor type will be UNKNOWN.
+	ActorTypeUnknown = "UNKNOWN"
+
+	// ActorTypeUser represents a user actor type.
+	// e.g if user authorized by JWT token, the actor type will be USER.
+	ActorTypeUser = "USER"
+
+	// ActorTypePat represents a personal access token actor type.
+	// e.g if user authorized by personal access token, the actor type will be PAT.
+	ActorTypePat = "PAT"
+)
+
+const (
+	// EventTypeAPI represents the event is from API operation.
+	EventTypeAPI = "API"
+)
+
+const (
+	// AuditStateSuccess represents the successful state.
+	AuditStateSuccess = "SUCCESS"
+
+	// AuditStateFailure represents the failure state.
+	AuditStateFailure = "FAILURE"
+)
+
+type Audit struct {
+	BaseModel
+	// ActorType represents the actor type, which can be one of the following:
+	// - UNKNOWN: Represents an unknown actor such as not authenticated.
+	// - USER: Represents a user.
+	// - PAT: Represents a personal access token.
+	ActorType string `gorm:"column:actor_type;type:varchar(256);not null;comment:actor type" json:"actor_type"`
+	// ActorName represents the actor name, it can be the username or token name.
+	ActorName string `gorm:"column:actor_name;type:varchar(256);not null;comment:actor name" json:"actor_name"`
+	// EventType represents the event type, indicates the type of event, API for http request,
+	// can expand to other types such as SYSTEM for internal system events in future.
+	EventType string `gorm:"column:event_type;type:varchar(100);not null;comment:event type" json:"event_type"`
+	// Operation represents the operation, it will be the HTTP method for API events.
+	Operation string `gorm:"column:operation;type:varchar(256);not null;comment:operation" json:"operation"`
+	// OperatedAt represents the operation time.
+	OperatedAt time.Time `gorm:"column:operated_at;type:timestamp;not null;comment:operation time" json:"operated_at"`
+	// State represents the state, it indicates the state of the operation, e.g SUCCESS for API status code >= 200 & < 300.
+	State string `gorm:"column:state;type:varchar(100);not null;comment:state" json:"state"`
+	// Path represents the request path, it will be the URL path for API events.
+	Path string `gorm:"column:path;type:varchar(1024);comment:request path" json:"path"`
+	// StatusCode represents the status code, can be ignored for non-API events.
+	StatusCode int `gorm:"column:status_code;type:int;comment:status code" json:"status_code"`
+	// Detail represents the detail, leave for extension for future use.
+	Detail JSONMap `gorm:"column:detail;comment:detail" json:"detail"`
+}
diff --git a/manager/router/router.go b/manager/router/router.go
index ec2b2eb1..5771c929 100644
--- a/manager/router/router.go
+++ b/manager/router/router.go
@@ -93,6 +93,9 @@ func Init(cfg *config.Config, logDir string, service service.Service, database *
 	// Personal access token middleware.
 	personalAccessToken := middlewares.PersonalAccessToken(database.DB)
 
+	// Audit middleware.
+	r.Use(middlewares.Audit(service))
+
 	// Error middleware.
 	r.Use(middlewares.Error())
 
@@ -239,6 +242,10 @@ func Init(cfg *config.Config, logDir string, service service.Service, database *
 	pc.GET(":id", h.GetPersistentCacheTask)
 	pc.GET("", h.GetPersistentCacheTasks)
 
+	// Audit.
+	at := apiv1.Group("/audits", jwt.MiddlewareFunc(), rbac)
+	at.GET("", h.GetAudits)
+
 	// Open API router.
 	oapiv1 := r.Group("/oapi/v1")
 
diff --git a/manager/service/audit.go b/manager/service/audit.go
new file mode 100644
index 00000000..98cdb2b2
--- /dev/null
+++ b/manager/service/audit.go
@@ -0,0 +1,129 @@
+/*
+ *     Copyright 2025 The Dragonfly Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package service
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	logger "d7y.io/dragonfly/v2/internal/dflog"
+	"d7y.io/dragonfly/v2/manager/models"
+	"d7y.io/dragonfly/v2/manager/types"
+)
+
+const (
+	// auditBatchInsertSize is the size for batch insertion.
+	auditBatchInsertSize = 500
+	// auditBatchInsertInterval is the interval for batch insertion.
+	auditBatchInsertInterval = 5 * time.Second
+	// auditBufferSize is the size of the audit channel buffer.
+	auditBufferSize = 1000
+)
+
+var (
+	auditCh chan *models.Audit
+	once    sync.Once
+)
+
+func (s *service) AsyncCreateAudit(ctx context.Context, json *types.CreateAuditRequest) error {
+	once.Do(func() {
+		auditCh = make(chan *models.Audit, auditBufferSize)
+		go s.processAudit()
+	})
+
+	audit := &models.Audit{
+		ActorType:  json.ActorType,
+		ActorName:  json.ActorName,
+		EventType:  json.EventType,
+		Operation:  json.Operation,
+		OperatedAt: json.OperatedAt,
+		State:      json.State,
+		Path:       json.Path,
+		StatusCode: json.StatusCode,
+	}
+
+	select {
+	case <-ctx.Done():
+		return fmt.Errorf("context done: %w", ctx.Err())
+	default:
+		select {
+		case auditCh <- audit:
+			return nil
+		default:
+			// Avoid to hang out the AsyncCreateAudit if the buffer is full.
+			return fmt.Errorf("audit buffer is full, buffer size: %d, drop the audit %#v", auditBufferSize, audit)
+		}
+	}
+}
+
+func (s *service) processAudit() {
+	// Use the new context as this is asynchronous operation.
+	ctx := context.Background()
+	audits := make([]*models.Audit, 0, auditBatchInsertSize)
+	ticker := time.NewTicker(auditBatchInsertInterval)
+	defer ticker.Stop()
+
+	createAuditInBatch := func(ctx context.Context, audits []*models.Audit) error {
+		if err := s.db.WithContext(ctx).CreateInBatches(&audits, len(audits)).Error; err != nil {
+			logger.Errorf("failed to create audit in batch: %v", err)
+			return err
+		}
+
+		return nil
+	}
+
+	for {
+		select {
+		case audit, ok := <-auditCh:
+			if !ok {
+				// Channel closed.
+				if len(audits) > 0 {
+					createAuditInBatch(ctx, audits) //nolint
+					return
+				}
+				return
+			}
+
+			audits = append(audits, audit)
+			if len(audits) >= auditBatchInsertSize {
+				if err := createAuditInBatch(ctx, audits); err == nil {
+					audits = make([]*models.Audit, 0, auditBatchInsertSize)
+				}
+
+				ticker.Reset(auditBatchInsertInterval)
+			}
+		case <-ticker.C:
+			if len(audits) > 0 {
+				if err := createAuditInBatch(ctx, audits); err == nil {
+					audits = make([]*models.Audit, 0, auditBatchInsertSize)
+				}
+			}
+		}
+	}
+}
+
+func (s *service) GetAudits(ctx context.Context, q types.GetAuditsQuery) ([]models.Audit, int64, error) {
+	var count int64
+	audits := []models.Audit{}
+	if err := s.db.WithContext(ctx).Scopes(models.Paginate(q.Page, q.PerPage)).Find(&audits).Limit(-1).Offset(-1).Count(&count).Error; err != nil {
+		return nil, 0, err
+	}
+
+	return audits, count, nil
+}
diff --git a/manager/service/mocks/service_mock.go b/manager/service/mocks/service_mock.go
index d1efdf25..656a860f 100644
--- a/manager/service/mocks/service_mock.go
+++ b/manager/service/mocks/service_mock.go
@@ -117,6 +117,20 @@ func (mr *MockServiceMockRecorder) AddSeedPeerToSeedPeerCluster(arg0, arg1, arg2
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSeedPeerToSeedPeerCluster", reflect.TypeOf((*MockService)(nil).AddSeedPeerToSeedPeerCluster), arg0, arg1, arg2)
 }
 
+// AsyncCreateAudit mocks base method.
+func (m *MockService) AsyncCreateAudit(ctx context.Context, json *types.CreateAuditRequest) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AsyncCreateAudit", ctx, json)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// AsyncCreateAudit indicates an expected call of AsyncCreateAudit.
+func (mr *MockServiceMockRecorder) AsyncCreateAudit(ctx, json any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AsyncCreateAudit", reflect.TypeOf((*MockService)(nil).AsyncCreateAudit), ctx, json)
+}
+
 // CreateApplication mocks base method.
 func (m *MockService) CreateApplication(arg0 context.Context, arg1 types.CreateApplicationRequest) (*models.Application, error) {
 	m.ctrl.T.Helper()
@@ -627,6 +641,22 @@ func (mr *MockServiceMockRecorder) GetApplications(arg0, arg1 any) *gomock.Call
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetApplications", reflect.TypeOf((*MockService)(nil).GetApplications), arg0, arg1)
 }
 
+// GetAudits mocks base method.
+func (m *MockService) GetAudits(ctx context.Context, q types.GetAuditsQuery) ([]models.Audit, int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAudits", ctx, q)
+	ret0, _ := ret[0].([]models.Audit)
+	ret1, _ := ret[1].(int64)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// GetAudits indicates an expected call of GetAudits.
+func (mr *MockServiceMockRecorder) GetAudits(ctx, q any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAudits", reflect.TypeOf((*MockService)(nil).GetAudits), ctx, q)
+}
+
 // GetBucket mocks base method.
 func (m *MockService) GetBucket(arg0 context.Context, arg1 string) (*objectstorage.BucketMetadata, error) {
 	m.ctrl.T.Helper()
diff --git a/manager/service/service.go b/manager/service/service.go
index 71c752fd..378c9738 100644
--- a/manager/service/service.go
+++ b/manager/service/service.go
@@ -141,6 +141,9 @@ type Service interface {
 	DestroyPersistentCacheTask(context.Context, uint, string) error
 	GetPersistentCacheTask(context.Context, uint, string) (types.PersistentCacheTask, error)
 	GetPersistentCacheTasks(context.Context, types.GetPersistentCacheTasksQuery) ([]types.PersistentCacheTask, int64, error)
+
+	AsyncCreateAudit(ctx context.Context, json *types.CreateAuditRequest) error
+	GetAudits(ctx context.Context, q types.GetAuditsQuery) ([]models.Audit, int64, error)
 }
 
 type service struct {
diff --git a/manager/types/audit.go b/manager/types/audit.go
new file mode 100644
index 00000000..6b3686ce
--- /dev/null
+++ b/manager/types/audit.go
@@ -0,0 +1,35 @@
+/*
+ *     Copyright 2025 The Dragonfly Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package types
+
+import "time"
+
+type CreateAuditRequest struct {
+	ActorType  string    `json:"actor_type" binding:"required"`
+	ActorName  string    `json:"actor_name" binding:"required"`
+	EventType  string    `json:"event_type" binding:"required"`
+	Operation  string    `json:"operation" binding:"required"`
+	OperatedAt time.Time `json:"operated_at" binding:"required"`
+	State      string    `json:"status" binding:"required"`
+	Path       string    `json:"path" binding:"omitempty"`
+	StatusCode int       `json:"status_code" binding:"omitempty"`
+}
+
+type GetAuditsQuery struct {
+	Page    int `form:"page" binding:"omitempty,gte=1"`
+	PerPage int `form:"per_page" binding:"omitempty,gte=1,lte=10000000"`
+}



Test patch:
diff --git a/manager/handlers/audit_test.go b/manager/handlers/audit_test.go
new file mode 100644
index 00000000..3eec0335
--- /dev/null
+++ b/manager/handlers/audit_test.go
@@ -0,0 +1,141 @@
+/*
+ *     Copyright 2025 The Dragonfly Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
+
+	"d7y.io/dragonfly/v2/manager/models"
+	"d7y.io/dragonfly/v2/manager/service/mocks"
+	"d7y.io/dragonfly/v2/manager/types"
+)
+
+var (
+	mockAuditModel = &models.Audit{
+		BaseModel: models.BaseModel{
+			ID:        1,
+			CreatedAt: time.Time{},
+			UpdatedAt: time.Time{},
+		},
+		ActorType:  "user",
+		ActorName:  "root",
+		EventType:  "API",
+		Operation:  "GET",
+		OperatedAt: time.Time{},
+		State:      "success",
+		Path:       "/api/v1/audits",
+		StatusCode: 200,
+	}
+)
+
+func mockAuditRouter(h *Handlers) *gin.Engine {
+	r := gin.Default()
+	apiv1 := r.Group("/api/v1")
+	audits := apiv1.Group("/audits")
+	audits.GET("", h.GetAudits)
+	return r
+}
+
+func TestHandlers_GetAudits(t *testing.T) {
+	tests := []struct {
+		name   string
+		req    *http.Request
+		mock   func(ms *mocks.MockServiceMockRecorder)
+		expect func(t *testing.T, w *httptest.ResponseRecorder)
+	}{
+		{
+			name: "unprocessable entity",
+			req:  httptest.NewRequest(http.MethodGet, "/api/v1/audits?page=-1", nil),
+			mock: func(ms *mocks.MockServiceMockRecorder) {},
+			expect: func(t *testing.T, w *httptest.ResponseRecorder) {
+				assert := assert.New(t)
+				assert.Equal(http.StatusUnprocessableEntity, w.Code)
+			},
+		},
+		{
+			name: "success",
+			req:  httptest.NewRequest(http.MethodGet, "/api/v1/audits?page=1&per_page=5", nil),
+			mock: func(ms *mocks.MockServiceMockRecorder) {
+				ms.GetAudits(gomock.Any(), gomock.Eq(types.GetAuditsQuery{
+					Page:    1,
+					PerPage: 5,
+				})).Return([]models.Audit{*mockAuditModel}, int64(1), nil).Times(1)
+			},
+			expect: func(t *testing.T, w *httptest.ResponseRecorder) {
+				assert := assert.New(t)
+				assert.Equal(http.StatusOK, w.Code)
+				var audits []models.Audit
+				err := json.Unmarshal(w.Body.Bytes(), &audits)
+				assert.NoError(err)
+				assert.Len(audits, 1)
+				assert.Equal(mockAuditModel, &audits[0])
+
+				// Check pagination header
+				linkHeader := w.Header().Get("Link")
+				assert.NotEmpty(linkHeader)
+				links := strings.Split(linkHeader, ",")
+				assert.Len(links, 4) // prev, next, first, last
+				assert.Contains(linkHeader, `rel=first`)
+				assert.Contains(linkHeader, `rel=last`)
+				assert.Contains(linkHeader, `rel=prev`)
+				assert.Contains(linkHeader, `rel=next`)
+			},
+		},
+		{
+			name: "success with default pagination",
+			req:  httptest.NewRequest(http.MethodGet, "/api/v1/audits", nil),
+			mock: func(ms *mocks.MockServiceMockRecorder) {
+				ms.GetAudits(gomock.Any(), gomock.Eq(types.GetAuditsQuery{
+					Page:    1,
+					PerPage: 10,
+				})).Return([]models.Audit{*mockAuditModel}, int64(1), nil).Times(1)
+			},
+			expect: func(t *testing.T, w *httptest.ResponseRecorder) {
+				assert := assert.New(t)
+				assert.Equal(http.StatusOK, w.Code)
+				var audits []models.Audit
+				err := json.Unmarshal(w.Body.Bytes(), &audits)
+				assert.NoError(err)
+				assert.Len(audits, 1)
+				assert.Equal(mockAuditModel, &audits[0])
+			},
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			ctl := gomock.NewController(t)
+			defer ctl.Finish()
+			svc := mocks.NewMockService(ctl)
+			w := httptest.NewRecorder()
+			h := New(svc)
+			mockRouter := mockAuditRouter(h)
+
+			tc.mock(svc.EXPECT())
+			mockRouter.ServeHTTP(w, tc.req)
+			tc.expect(t, w)
+		})
+	}
+}
diff --git a/test/tools/no-content-length/Dockerfile b/test/tools/no-content-length/Dockerfile
index a341e2b2..6b138d1b 100644
--- a/test/tools/no-content-length/Dockerfile
+++ b/test/tools/no-content-length/Dockerfile
@@ -1,6 +1,6 @@
 ARG BASE_IMAGE=alpine:3.20
 
-FROM golang:1.23.0-alpine3.20 AS builder
+FROM golang:1.23.8-alpine3.20 AS builder
 
 COPY . /go/src/
 
@@ -21,4 +21,3 @@ EXPOSE 80
 WORKDIR /
 
 ENTRYPOINT ["/usr/local/bin/no-content-length"]
-