1
0
forked from baron/baron-sso

DevFront 감사로그 조회 API 추가와 액션 필터링 및 테스트 보강

This commit is contained in:
2026-02-27 17:50:53 +09:00
parent 45d1d3b910
commit 914b1b0d49
3 changed files with 334 additions and 0 deletions

View File

@@ -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)

View File

@@ -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 ""
}

View File

@@ -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)
}