forked from baron/baron-sso
DevFront 감사로그 조회 API 추가와 액션 필터링 및 테스트 보강
This commit is contained in:
@@ -277,6 +277,7 @@ func main() {
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
|
||||
devHandler.AuditRepo = auditRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
@@ -633,6 +634,7 @@ func main() {
|
||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||
dev.Get("/consents", devHandler.ListConsents)
|
||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||
|
||||
// Webhook for Kratos courier (HTTP delivery)
|
||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||
|
||||
@@ -23,6 +23,7 @@ type DevHandler struct {
|
||||
Hydra *service.HydraAdminService
|
||||
Redis domain.RedisRepository
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
@@ -53,6 +54,7 @@ func NewDevHandler(
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
SecretRepo: secretRepo,
|
||||
AuditRepo: nil,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
@@ -61,6 +63,13 @@ func NewDevHandler(
|
||||
}
|
||||
}
|
||||
|
||||
type devAuditListResponse struct {
|
||||
Items []domain.AuditLog `json:"items"`
|
||||
Limit int `json:"limit"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
type clientSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -260,6 +269,7 @@ func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
h.injectTenantContextFromHeader(c)
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
if limit <= 0 {
|
||||
@@ -304,6 +314,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
h.injectTenantContextFromHeader(c)
|
||||
clientID := c.Params("id")
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
@@ -343,6 +354,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := c.Params("id")
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
@@ -372,6 +384,22 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
beforeStatus := ""
|
||||
if current != nil {
|
||||
beforeStatus = h.mapClientSummary(*current).Status
|
||||
}
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "UPDATE_CLIENT_STATUS",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
"before": map[string]any{
|
||||
"status": beforeStatus,
|
||||
},
|
||||
"after": map[string]any{
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
|
||||
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
@@ -394,6 +422,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
var req clientUpsertRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -443,6 +472,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if metadata == nil {
|
||||
metadata = map[string]interface{}{}
|
||||
}
|
||||
if tenantID != "" {
|
||||
metadata["tenant_id"] = tenantID
|
||||
}
|
||||
metadata["status"] = status
|
||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
@@ -466,6 +498,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "CREATE_CLIENT",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
"after": map[string]any{
|
||||
"type": clientType,
|
||||
"status": status,
|
||||
"redirect_uri_count": len(redirectURIs),
|
||||
"scope_count": len(scopes),
|
||||
},
|
||||
})
|
||||
|
||||
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -484,6 +528,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{"target_id": created.ClientID})
|
||||
|
||||
summary := h.mapClientSummary(*created)
|
||||
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
@@ -498,6 +544,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
@@ -576,6 +623,22 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "UPDATE_CLIENT",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
"before": map[string]any{
|
||||
"name": currentSummary.Name,
|
||||
"type": currentSummary.Type,
|
||||
"status": currentSummary.Status,
|
||||
},
|
||||
"after": map[string]any{
|
||||
"name": strings.TrimSpace(updated.ClientName),
|
||||
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
|
||||
"status": resolveStatusFromMetadata(updated.Metadata),
|
||||
},
|
||||
})
|
||||
|
||||
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
@@ -598,6 +661,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
@@ -615,6 +679,12 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "DELETE_CLIENT",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
|
||||
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
@@ -636,6 +706,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
h.injectTenantContextFromHeader(c)
|
||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client_id is required")
|
||||
@@ -737,6 +808,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
subject := strings.TrimSpace(c.Query("subject"))
|
||||
if subject == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
|
||||
@@ -751,6 +823,15 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "REVOKE_CONSENT",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
"after": map[string]any{
|
||||
"subject": subject,
|
||||
},
|
||||
})
|
||||
|
||||
// 1. Revoke in Hydra
|
||||
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -765,6 +846,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
@@ -782,6 +864,12 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "ROTATE_SECRET",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
|
||||
// 1. Generate new secret
|
||||
newSecret, err := generateRandomSecret(20)
|
||||
if err != nil {
|
||||
@@ -831,6 +919,89 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
||||
if h.AuditRepo == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||
}
|
||||
|
||||
h.injectTenantContextFromHeader(c)
|
||||
allowed, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
}
|
||||
if !allowed {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action")))
|
||||
clientFilter := strings.TrimSpace(c.Query("client_id"))
|
||||
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
tenantFilter := strings.TrimSpace(c.Query("tenant_id"))
|
||||
if tenantFilter == "" {
|
||||
tenantFilter = h.resolveDevTenantScope(c)
|
||||
}
|
||||
|
||||
cursorRaw := c.Query("cursor")
|
||||
cursor, err := parseAuditCursor(cursorRaw)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
|
||||
}
|
||||
|
||||
collected := make([]domain.AuditLog, 0, limit+1)
|
||||
nextCursor := cursor
|
||||
scanned := 0
|
||||
const pageSize = 100
|
||||
const maxScan = 3000
|
||||
|
||||
for len(collected) < limit+1 && scanned < maxScan {
|
||||
page, findErr := h.AuditRepo.FindPage(c.Context(), pageSize, nextCursor)
|
||||
if findErr != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs")
|
||||
}
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, logItem := range page {
|
||||
scanned++
|
||||
if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter) {
|
||||
collected = append(collected, logItem)
|
||||
if len(collected) == limit+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last := page[len(page)-1]
|
||||
nextCursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID}
|
||||
if len(page) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
nextCursorRaw := ""
|
||||
if len(collected) > limit {
|
||||
last := collected[limit-1]
|
||||
nextCursorRaw = encodeAuditCursor(last)
|
||||
collected = collected[:limit]
|
||||
}
|
||||
|
||||
return c.JSON(devAuditListResponse{
|
||||
Items: collected,
|
||||
Limit: limit,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursorRaw,
|
||||
})
|
||||
}
|
||||
|
||||
func generateRandomSecret(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
@@ -963,3 +1134,109 @@ func resolveTokenAuthMethod(requested, fallback string) string {
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func (h *DevHandler) injectTenantContextFromHeader(c *fiber.Ctx) string {
|
||||
tenantID := strings.TrimSpace(c.Get("X-Tenant-ID"))
|
||||
if tenantID != "" {
|
||||
c.Locals("tenant_id", tenantID)
|
||||
}
|
||||
return tenantID
|
||||
}
|
||||
|
||||
func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) {
|
||||
if c == nil || len(extra) == 0 {
|
||||
return
|
||||
}
|
||||
if existing := c.Locals("audit_details_extra"); existing != nil {
|
||||
if m, ok := existing.(map[string]any); ok {
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
c.Locals("audit_details_extra", m)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Locals("audit_details_extra", extra)
|
||||
}
|
||||
|
||||
func normalizeAuditAction(eventType string, details map[string]any) string {
|
||||
if raw, ok := details["action"].(string); ok && strings.TrimSpace(raw) != "" {
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
normalized := strings.TrimSpace(eventType)
|
||||
switch {
|
||||
case normalized == "POST /api/v1/dev/clients":
|
||||
return "CREATE_CLIENT"
|
||||
case strings.HasPrefix(normalized, "PUT /api/v1/dev/clients/"):
|
||||
return "UPDATE_CLIENT"
|
||||
case strings.HasPrefix(normalized, "PATCH /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/status"):
|
||||
return "UPDATE_CLIENT_STATUS"
|
||||
case strings.HasPrefix(normalized, "POST /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/secret/rotate"):
|
||||
return "ROTATE_SECRET"
|
||||
case strings.HasPrefix(normalized, "DELETE /api/v1/dev/clients/"):
|
||||
return "DELETE_CLIENT"
|
||||
case normalized == "DELETE /api/v1/dev/consents":
|
||||
return "REVOKE_CONSENT"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStatusFromMetadata(metadata map[string]interface{}) string {
|
||||
if metadata != nil {
|
||||
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
|
||||
return "inactive"
|
||||
}
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func clientTypeOrDefault(tokenEndpointAuthMethod string) string {
|
||||
if strings.EqualFold(tokenEndpointAuthMethod, "none") {
|
||||
return "pkce"
|
||||
}
|
||||
return "private"
|
||||
}
|
||||
|
||||
func (h *DevHandler) matchesDevAuditFilter(
|
||||
logItem domain.AuditLog,
|
||||
tenantFilter, clientFilter, actionFilter, statusFilter string,
|
||||
) bool {
|
||||
if !strings.Contains(logItem.EventType, "/api/v1/dev/") {
|
||||
return false
|
||||
}
|
||||
details, _ := parseAuditDetails(logItem.Details)
|
||||
if statusFilter != "" && statusFilter != "all" && strings.ToLower(logItem.Status) != statusFilter {
|
||||
return false
|
||||
}
|
||||
if tenantFilter != "" {
|
||||
detailTenant, _ := details["tenant_id"].(string)
|
||||
if strings.TrimSpace(detailTenant) != tenantFilter {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if clientFilter != "" {
|
||||
targetID, _ := details["target_id"].(string)
|
||||
clientID, _ := details["client_id"].(string)
|
||||
if strings.TrimSpace(targetID) != clientFilter && strings.TrimSpace(clientID) != clientFilter {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if actionFilter != "" {
|
||||
if normalizeAuditAction(logItem.EventType, details) != actionFilter {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
|
||||
fromHeader := strings.TrimSpace(c.Get("X-Tenant-ID"))
|
||||
if fromHeader != "" {
|
||||
return fromHeader
|
||||
}
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil && profile.TenantID != nil {
|
||||
return strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -172,3 +173,57 @@ func TestCreateClient_Success(t *testing.T) {
|
||||
secret, _ := secretRepo.GetByID(nil, "new-client-123")
|
||||
assert.Equal(t, "secret-123", secret)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_FilterByActionAndClientID(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
EventID: "evt-1",
|
||||
Timestamp: now,
|
||||
UserID: "user-a",
|
||||
EventType: "PUT /api/v1/dev/clients/client-1",
|
||||
Status: "success",
|
||||
Details: `{"action":"UPDATE_CLIENT","target_id":"client-1","tenant_id":"tenant-a"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-2",
|
||||
Timestamp: now.Add(-time.Minute),
|
||||
UserID: "user-a",
|
||||
EventType: "DELETE /api/v1/dev/clients/client-1",
|
||||
Status: "success",
|
||||
Details: `{"action":"DELETE_CLIENT","target_id":"client-1","tenant_id":"tenant-a"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-3",
|
||||
Timestamp: now.Add(-2 * time.Minute),
|
||||
UserID: "user-b",
|
||||
EventType: "PUT /api/v1/dev/clients/client-2",
|
||||
Status: "failure",
|
||||
Details: `{"action":"UPDATE_CLIENT","target_id":"client-2","tenant_id":"tenant-b"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
AuditRepo: auditRepo,
|
||||
Keto: new(MockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?action=UPDATE_CLIENT&client_id=client-1&status=success", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res devAuditListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.Len(t, res.Items, 1)
|
||||
assert.Equal(t, "evt-1", res.Items[0].EventID)
|
||||
assert.Equal(t, "success", res.Items[0].Status)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user