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)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||||
adminHandler := handler.NewAdminHandler(ketoService)
|
adminHandler := handler.NewAdminHandler(ketoService)
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
|
||||||
|
devHandler.AuditRepo = auditRepo
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
@@ -633,6 +634,7 @@ func main() {
|
|||||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||||
dev.Get("/consents", devHandler.ListConsents)
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
|
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||||
|
|
||||||
// Webhook for Kratos courier (HTTP delivery)
|
// Webhook for Kratos courier (HTTP delivery)
|
||||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type DevHandler struct {
|
|||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
Redis domain.RedisRepository
|
Redis domain.RedisRepository
|
||||||
SecretRepo domain.ClientSecretRepository
|
SecretRepo domain.ClientSecretRepository
|
||||||
|
AuditRepo domain.AuditRepository
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
@@ -53,6 +54,7 @@ func NewDevHandler(
|
|||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
|
AuditRepo: nil,
|
||||||
KratosAdmin: service.NewKratosAdminService(),
|
KratosAdmin: service.NewKratosAdminService(),
|
||||||
ConsentRepo: consentRepo,
|
ConsentRepo: consentRepo,
|
||||||
Keto: keto,
|
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 {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -260,6 +269,7 @@ func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||||
|
h.injectTenantContextFromHeader(c)
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@@ -304,6 +314,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||||
|
h.injectTenantContextFromHeader(c)
|
||||||
clientID := c.Params("id")
|
clientID := c.Params("id")
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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 {
|
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := c.Params("id")
|
clientID := c.Params("id")
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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)
|
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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 {
|
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
var req clientUpsertRequest
|
var req clientUpsertRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
@@ -443,6 +472,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
if tenantID != "" {
|
||||||
|
metadata["tenant_id"] = tenantID
|
||||||
|
}
|
||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
@@ -466,6 +498,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
Metadata: metadata,
|
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)
|
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
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)
|
summary := h.mapClientSummary(*created)
|
||||||
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
|
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
|
||||||
Client: summary,
|
Client: summary,
|
||||||
@@ -498,6 +544,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||||
@@ -576,6 +623,22 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
Metadata: metadata,
|
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)
|
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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 {
|
func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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 err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
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 {
|
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||||
|
h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client_id is required")
|
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 {
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
|
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
|
// 1. Revoke in Hydra
|
||||||
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
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 {
|
func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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
|
// 1. Generate new secret
|
||||||
newSecret, err := generateRandomSecret(20)
|
newSecret, err := generateRandomSecret(20)
|
||||||
if err != nil {
|
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) {
|
func generateRandomSecret(length int) (string, error) {
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
@@ -963,3 +1134,109 @@ func resolveTokenAuthMethod(requested, fallback string) string {
|
|||||||
}
|
}
|
||||||
return requested
|
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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -172,3 +173,57 @@ func TestCreateClient_Success(t *testing.T) {
|
|||||||
secret, _ := secretRepo.GetByID(nil, "new-client-123")
|
secret, _ := secretRepo.GetByID(nil, "new-client-123")
|
||||||
assert.Equal(t, "secret-123", secret)
|
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