diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 6ade571a..40e4bb8a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -8,6 +8,7 @@ import ( "baron-sso-backend/internal/utils" "context" "encoding/csv" + "encoding/json" "errors" "fmt" "log/slog" @@ -1768,7 +1769,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error { } } - if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), id); err != nil { + if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, id); err != nil { results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) continue } @@ -2227,7 +2228,7 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { } } - if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), userID); err != nil { + if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -2264,11 +2265,20 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } -func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, userID string) error { +func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, requester *domain.UserProfileResponse, userID string) error { if h.KetoService == nil || h.KetoOutboxRepo == nil { return nil } + actorID := "" + tenantID := "" + if requester != nil { + actorID = strings.TrimSpace(requester.ID) + if requester.TenantID != nil { + tenantID = strings.TrimSpace(*requester.TenantID) + } + } + subject := "User:" + strings.TrimSpace(userID) tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject) if err != nil { @@ -2314,12 +2324,65 @@ func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, Action: domain.KetoOutboxActionDelete, }); err != nil { slog.Warn("[UserHandler] Failed to enqueue RelyingParty relation cleanup", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err) + continue + } + + if err := h.recordDeletedUserRelyingPartyCleanupAudit(actorID, tenantID, userID, tuple, relSubject); err != nil { + slog.Warn("[UserHandler] Failed to record RelyingParty cleanup audit", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err) } } return nil } +func (h *UserHandler) recordDeletedUserRelyingPartyCleanupAudit( + actorID string, + tenantID string, + deletedUserID string, + tuple service.RelationTuple, + relSubject string, +) error { + if h.AuditRepo == nil { + return nil + } + + details := map[string]any{ + "action": "REMOVE_RELATION", + "target_id": strings.TrimSpace(tuple.Object), + "source": "user_delete", + "deleted_user_id": strings.TrimSpace(deletedUserID), + "cascade_cleanup": true, + "relation_subject": strings.TrimSpace(relSubject), + "before": map[string]any{ + "relation": strings.TrimSpace(tuple.Relation), + "subject": strings.TrimSpace(relSubject), + }, + } + if strings.TrimSpace(tenantID) != "" { + details["tenant_id"] = strings.TrimSpace(tenantID) + } + + raw, err := json.Marshal(details) + if err != nil { + return err + } + + eventType := fmt.Sprintf("DELETE /api/v1/dev/clients/%s/relations", strings.TrimSpace(tuple.Object)) + if strings.TrimSpace(tuple.Relation) != "" { + eventType = fmt.Sprintf("%s/%s", eventType, strings.TrimSpace(tuple.Relation)) + } + + return h.AuditRepo.Create(&domain.AuditLog{ + EventID: fmt.Sprintf("user-delete-rp-cleanup-%d", time.Now().UnixNano()), + Timestamp: time.Now().UTC(), + UserID: strings.TrimSpace(actorID), + TenantID: strings.TrimSpace(tenantID), + EventType: eventType, + Status: "success", + Details: string(raw), + }) +} + func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) { var tuples []service.RelationTuple var err error diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index f1ee6229..91563777 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -1293,6 +1293,66 @@ func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t mockOutbox.AssertExpectations(t) } +func TestUserHandler_DeleteUserRecordsCascadeRelyingPartyCleanupAudit(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + userRepo := new(MockUserRepoForHandler) + mockKeto := new(userHandlerMockKetoService) + mockOutbox := new(userHandlerMockKetoOutboxRepository) + auditRepo := &mockAuditRepo{} + h := &UserHandler{ + KratosAdmin: mockKratos, + UserRepo: userRepo, + KetoService: mockKeto, + KetoOutboxRepo: mockOutbox, + AuditRepo: auditRepo, + } + + app.Delete("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin}) + return h.DeleteUser(c) + }) + + mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{ + {Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"}, + }, nil).Once() + mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once() + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { + return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete + })).Return(nil).Once() + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { + return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete + })).Return(nil).Once() + mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() + + req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + require.Len(t, auditRepo.logs, 1) + log := auditRepo.logs[0] + assert.Equal(t, "admin-1", log.UserID) + assert.Equal(t, "DELETE /api/v1/dev/clients/client-1/relations/admins", log.EventType) + + details := map[string]any{} + require.NoError(t, json.Unmarshal([]byte(log.Details), &details)) + assert.Equal(t, "REMOVE_RELATION", details["action"]) + assert.Equal(t, "client-1", details["target_id"]) + assert.Equal(t, "user_delete", details["source"]) + assert.Equal(t, "u-1", details["deleted_user_id"]) + assert.Equal(t, "User:u-1", details["relation_subject"]) + + before, ok := details["before"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "admins", before["relation"]) + assert.Equal(t, "User:u-1", before["subject"]) + + mockKratos.AssertExpectations(t) + mockKeto.AssertExpectations(t) + mockOutbox.AssertExpectations(t) +} + func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin)