From 914b1b0d49bb73d9085bb37a0ad8ae3f76069a6f Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 27 Feb 2026 17:50:53 +0900 Subject: [PATCH] =?UTF-8?q?DevFront=20=EA=B0=90=EC=82=AC=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=EC=99=80=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=ED=95=84=ED=84=B0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 + backend/internal/handler/dev_handler.go | 277 +++++++++++++++++++ backend/internal/handler/dev_handler_test.go | 55 ++++ 3 files changed, 334 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index dd930a94..325aa46a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index bbc31675..282c3d45 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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 "" +} diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 7647b30c..acbfcd1c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -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) +}