Просмотр исходного кода

Merge branch 'master' of github.com:provectus/kafka-ui into metrics

 Conflicts:
	documentation/compose/jmx-exporter/kafka-broker.yml
	documentation/compose/kafka-ui-arm64.yaml
	kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java
	kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/integration/odd/TopicsExporter.java
iliax 2 лет назад
Родитель
Сommit
9f2bca162c
93 измененных файлов с 2331 добавлено и 545 удалено
  1. 1 1
      .github/workflows/aws_publisher.yaml
  2. 3 3
      .github/workflows/backend.yml
  3. 1 1
      .github/workflows/block_merge.yml
  4. 1 1
      .github/workflows/branch-deploy.yml
  5. 1 1
      .github/workflows/branch-remove.yml
  6. 2 2
      .github/workflows/build-public-image.yml
  7. 1 1
      .github/workflows/delete-public-image.yml
  8. 1 1
      .github/workflows/documentation.yaml
  9. 1 1
      .github/workflows/e2e-automation.yml
  10. 2 2
      .github/workflows/e2e-checks.yaml
  11. 1 1
      .github/workflows/e2e-manual.yml
  12. 1 1
      .github/workflows/e2e-weekly.yml
  13. 2 2
      .github/workflows/frontend.yaml
  14. 2 1
      .github/workflows/master.yaml
  15. 2 2
      .github/workflows/pr-checks.yaml
  16. 1 1
      .github/workflows/release-serde-api.yaml
  17. 1 1
      .github/workflows/release.yaml
  18. 1 1
      .github/workflows/release_drafter.yml
  19. 1 1
      .github/workflows/separate_env_public_create.yml
  20. 1 1
      .github/workflows/separate_env_public_remove.yml
  21. 1 1
      .github/workflows/stale.yaml
  22. 1 1
      .github/workflows/terraform-deploy.yml
  23. 1 1
      .github/workflows/triage_issues.yml
  24. 1 1
      .github/workflows/triage_prs.yml
  25. 1 1
      .github/workflows/welcome-first-time-contributors.yml
  26. 1 1
      .github/workflows/workflow_linter.yaml
  27. 3 0
      .gitignore
  28. 1 68
      documentation/compose/jmx-exporter/kafka-broker.yml
  29. 4 4
      documentation/compose/kafka-ui-arm64.yaml
  30. 1 1
      kafka-ui-api/pom.xml
  31. 12 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java
  32. 31 8
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CorsGlobalConfiguration.java
  33. 2 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapProperties.java
  34. 25 10
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapSecurityConfig.java
  35. 12 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/AclsController.java
  36. 32 24
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/ApplicationConfigController.java
  37. 53 30
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java
  38. 11 5
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/ClustersController.java
  39. 33 22
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/ConsumerGroupsController.java
  40. 74 47
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/KafkaConnectController.java
  41. 37 25
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/KsqlController.java
  42. 40 15
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/MessagesController.java
  43. 64 35
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java
  44. 89 63
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/TopicsController.java
  45. 7 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/JsonAvroConversionException.java
  46. 0 7
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/JsonToAvroConversionException.java
  47. 34 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/AccessContext.java
  48. 5 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/Permission.java
  49. 2 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/Resource.java
  50. 14 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/permission/AuditAction.java
  51. 5 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/permission/PermissibleAction.java
  52. 24 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/SerdesInitializer.java
  53. 294 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/builtin/ConsumerOffsetsSerde.java
  54. 6 14
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/builtin/ProtobufFileSerde.java
  55. 2 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java
  56. 52 7
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/MessagesService.java
  57. 9 12
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/TopicsService.java
  58. 97 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/audit/AuditRecord.java
  59. 209 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/audit/AuditService.java
  60. 78 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/audit/AuditWriter.java
  61. 3 6
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/integration/odd/OddExporter.java
  62. 5 5
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/integration/odd/TopicsExporter.java
  63. 19 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java
  64. 78 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java
  65. 11 25
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/jsonschema/AvroJsonSchemaConverter.java
  66. 61 22
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/jsonschema/JsonAvroConversion.java
  67. 3 0
      kafka-ui-api/src/main/resources/application-local.yml
  68. 2 0
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/AbstractIntegrationTest.java
  69. 185 0
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/serdes/builtin/ConsumerOffsetsSerdeTest.java
  70. 1 14
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/serdes/builtin/ProtobufFileSerdeTest.java
  71. 41 0
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/MessagesServiceTest.java
  72. 3 1
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java
  73. 3 3
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/TopicsServicePaginationTest.java
  74. 87 0
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/audit/AuditIntegrationTest.java
  75. 154 0
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/audit/AuditServiceTest.java
  76. 3 1
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/integration/odd/TopicsExporterTest.java
  77. 0 1
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/util/AccessControlServiceMock.java
  78. 94 2
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/util/jsonschema/JsonAvroConversionTest.java
  79. 66 0
      kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
  80. 3 3
      kafka-ui-e2e-checks/pom.xml
  81. 15 0
      kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/utilities/StringUtils.java
  82. 2 10
      kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java
  83. 34 0
      kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/brokers/BrokersTest.java
  84. 1 1
      kafka-ui-react-app/public/robots.txt
  85. 4 1
      kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx
  86. 1 1
      kafka-ui-react-app/src/components/Brokers/Broker/__test__/Broker.spec.tsx
  87. 1 0
      kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx
  88. 16 4
      kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx
  89. 18 3
      kafka-ui-react-app/src/components/ConsumerGroups/List.tsx
  90. 3 3
      kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx
  91. 12 1
      kafka-ui-react-app/src/lib/constants.ts
  92. 5 0
      kafka-ui-react-app/src/lib/hooks/api/topics.ts
  93. 2 2
      pom.xml

+ 1 - 1
.github/workflows/aws_publisher.yaml

@@ -1,4 +1,4 @@
-name: AWS Marketplace Publisher
+name: "Infra: Release: AWS Marketplace Publisher"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
     inputs:
     inputs:

+ 3 - 3
.github/workflows/backend.yml

@@ -1,9 +1,9 @@
-name: Backend build and test
+name: "Backend: PR/master build & test"
 on:
 on:
   push:
   push:
     branches:
     branches:
       - master
       - master
-  pull_request_target:
+  pull_request:
     types: ["opened", "edited", "reopened", "synchronize"]
     types: ["opened", "edited", "reopened", "synchronize"]
     paths:
     paths:
       - "kafka-ui-api/**"
       - "kafka-ui-api/**"
@@ -29,7 +29,7 @@ jobs:
           key: ${{ runner.os }}-sonar
           key: ${{ runner.os }}-sonar
           restore-keys: ${{ runner.os }}-sonar
           restore-keys: ${{ runner.os }}-sonar
       - name: Build and analyze pull request target
       - name: Build and analyze pull request target
-        if: ${{ github.event_name == 'pull_request_target' }}
+        if: ${{ github.event_name == 'pull_request' }}
         env:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }}
           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }}

+ 1 - 1
.github/workflows/block_merge.yml

@@ -1,4 +1,4 @@
-name: Pull Request Labels
+name: "Infra: PR block merge"
 on:
 on:
   pull_request:
   pull_request:
     types: [opened, labeled, unlabeled, synchronize]
     types: [opened, labeled, unlabeled, synchronize]

+ 1 - 1
.github/workflows/branch-deploy.yml

@@ -1,4 +1,4 @@
-name: Feature testing init
+name: "Infra: Feature Testing: Init env"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
 
 

+ 1 - 1
.github/workflows/branch-remove.yml

@@ -1,4 +1,4 @@
-name: Feature testing destroy
+name: "Infra: Feature Testing: Destroy env"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
   pull_request:
   pull_request:

+ 2 - 2
.github/workflows/build-public-image.yml

@@ -1,4 +1,4 @@
-name: Build Docker image and push
+name: "Infra: Image Testing: Deploy"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
   pull_request:
   pull_request:
@@ -65,7 +65,7 @@ jobs:
           cache-from: type=local,src=/tmp/.buildx-cache
           cache-from: type=local,src=/tmp/.buildx-cache
           cache-to: type=local,dest=/tmp/.buildx-cache
           cache-to: type=local,dest=/tmp/.buildx-cache
       - name: make comment with private deployment link
       - name: make comment with private deployment link
-        uses: peter-evans/create-or-update-comment@v2
+        uses: peter-evans/create-or-update-comment@v3
         with:
         with:
           issue-number: ${{ github.event.pull_request.number }}
           issue-number: ${{ github.event.pull_request.number }}
           body: |
           body: |

+ 1 - 1
.github/workflows/delete-public-image.yml

@@ -1,4 +1,4 @@
-name: Delete Public ECR Image
+name: "Infra: Image Testing: Delete"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
   pull_request:
   pull_request:

+ 1 - 1
.github/workflows/documentation.yaml

@@ -1,4 +1,4 @@
-name: Documentation URLs linter
+name: "Infra: Docs: URL linter"
 on:
 on:
   pull_request:
   pull_request:
     types:
     types:

+ 1 - 1
.github/workflows/e2e-automation.yml

@@ -1,4 +1,4 @@
-name: E2E Automation suite
+name: "E2E: Automation suite"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
     inputs:
     inputs:

+ 2 - 2
.github/workflows/e2e-checks.yaml

@@ -1,6 +1,6 @@
-name: E2E PR health check
+name: "E2E: PR healthcheck"
 on:
 on:
-  pull_request_target:
+  pull_request:
     types: [ "opened", "edited", "reopened", "synchronize" ]
     types: [ "opened", "edited", "reopened", "synchronize" ]
     paths:
     paths:
       - "kafka-ui-api/**"
       - "kafka-ui-api/**"

+ 1 - 1
.github/workflows/e2e-manual.yml

@@ -1,4 +1,4 @@
-name: E2E Manual suite
+name: "E2E: Manual suite"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
     inputs:
     inputs:

+ 1 - 1
.github/workflows/e2e-weekly.yml

@@ -1,4 +1,4 @@
-name: E2E Weekly suite
+name: "E2E: Weekly suite"
 on:
 on:
   schedule:
   schedule:
     - cron: '0 1 * * 1'
     - cron: '0 1 * * 1'

+ 2 - 2
.github/workflows/frontend.yaml

@@ -1,9 +1,9 @@
-name: Frontend build and test
+name: "Frontend: PR/master build & test"
 on:
 on:
   push:
   push:
     branches:
     branches:
       - master
       - master
-  pull_request_target:
+  pull_request:
     types: ["opened", "edited", "reopened", "synchronize"]
     types: ["opened", "edited", "reopened", "synchronize"]
     paths:
     paths:
       - "kafka-ui-contract/**"
       - "kafka-ui-contract/**"

+ 2 - 1
.github/workflows/master.yaml

@@ -1,4 +1,4 @@
-name: Master branch build & deploy
+name: "Master: Build & deploy"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
   push:
   push:
@@ -58,6 +58,7 @@ jobs:
           builder: ${{ steps.buildx.outputs.name }}
           builder: ${{ steps.buildx.outputs.name }}
           context: kafka-ui-api
           context: kafka-ui-api
           platforms: linux/amd64,linux/arm64
           platforms: linux/amd64,linux/arm64
+          provenance: false
           push: true
           push: true
           tags: |
           tags: |
             provectuslabs/kafka-ui:${{ steps.build.outputs.version }}
             provectuslabs/kafka-ui:${{ steps.build.outputs.version }}

+ 2 - 2
.github/workflows/pr-checks.yaml

@@ -1,6 +1,6 @@
-name: "PR Checklist checked"
+name: "PR: Checklist linter"
 on:
 on:
-  pull_request_target:
+  pull_request:
     types: [opened, edited, synchronize, reopened]
     types: [opened, edited, synchronize, reopened]
 
 
 jobs:
 jobs:

+ 1 - 1
.github/workflows/release-serde-api.yaml

@@ -1,4 +1,4 @@
-name: Release serde api
+name: "Infra: Release: Serde API"
 on: workflow_dispatch
 on: workflow_dispatch
 
 
 jobs:
 jobs:

+ 1 - 1
.github/workflows/release.yaml

@@ -1,4 +1,4 @@
-name: Release
+name: "Infra: Release"
 on:
 on:
   release:
   release:
     types: [published]
     types: [published]

+ 1 - 1
.github/workflows/release_drafter.yml

@@ -1,4 +1,4 @@
-name: Release Drafter
+name: "Infra: Release Drafter run"
 
 
 on:
 on:
   push:
   push:

+ 1 - 1
.github/workflows/separate_env_public_create.yml

@@ -1,4 +1,4 @@
-name: Separate environment create
+name: "Infra: Feature Testing Public: Init env"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
     inputs:
     inputs:

+ 1 - 1
.github/workflows/separate_env_public_remove.yml

@@ -1,4 +1,4 @@
-name: Separate environment remove
+name: "Infra: Feature Testing Public: Destroy env"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
     inputs:
     inputs:

+ 1 - 1
.github/workflows/stale.yaml

@@ -1,4 +1,4 @@
-name: 'Close stale issues'
+name: 'Infra: Close stale issues'
 on:
 on:
   schedule:
   schedule:
     - cron: '30 1 * * *'
     - cron: '30 1 * * *'

+ 1 - 1
.github/workflows/terraform-deploy.yml

@@ -1,4 +1,4 @@
-name: Terraform deploy
+name: "Infra: Terraform deploy"
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
     inputs:
     inputs:

+ 1 - 1
.github/workflows/triage_issues.yml

@@ -1,4 +1,4 @@
-name: Add triage label to new issues
+name: "Infra: Triage: Apply triage label for issues"
 on:
 on:
   issues:
   issues:
     types:
     types:

+ 1 - 1
.github/workflows/triage_prs.yml

@@ -1,4 +1,4 @@
-name: Add triage label to new PRs
+name: "Infra: Triage: Apply triage label for PRs"
 on:
 on:
   pull_request:
   pull_request:
     types:
     types:

+ 1 - 1
.github/workflows/welcome-first-time-contributors.yml

@@ -1,7 +1,7 @@
 name: Welcome first time contributors
 name: Welcome first time contributors
 
 
 on:
 on:
-  pull_request_target:
+  pull_request:
     types:
     types:
       - opened
       - opened
   issues:
   issues:

+ 1 - 1
.github/workflows/workflow_linter.yaml

@@ -1,4 +1,4 @@
-name: "Workflow linter"
+name: "Infra: Workflow linter"
 on:
 on:
   pull_request:
   pull_request:
     types:
     types:

+ 3 - 0
.gitignore

@@ -31,6 +31,9 @@ build/
 .vscode/
 .vscode/
 /kafka-ui-api/app/node
 /kafka-ui-api/app/node
 
 
+### SDKMAN ###
+.sdkmanrc
+
 .DS_Store
 .DS_Store
 *.code-workspace
 *.code-workspace
 
 

+ 1 - 68
documentation/compose/jmx-exporter/kafka-broker.yml

@@ -1,69 +1,2 @@
-lowercaseOutputName: true
 rules:
 rules:
-  # Special cases and very specific rules
-  - pattern: kafka.server<type=(.+), name=(.+), clientId=(.+), topic=(.+), partition=(.*)><>Value
-    name: kafka_server_$1_$2
-    type: GAUGE
-    labels:
-      clientId: '$3'
-      topic: '$4'
-      partition: '$5'
-  - pattern: kafka.server<type=(.+), name=(.+), clientId=(.+), brokerHost=(.+), brokerPort=(.+)><>Value
-    name: kafka_server_$1_$2
-    type: GAUGE
-    labels:
-      clientId: '$3'
-      broker: '$4:$5'
-
-  - pattern: kafka.server<type=KafkaRequestHandlerPool, name=RequestHandlerAvgIdlePercent><>OneMinuteRate
-    name: kafka_server_kafkarequesthandlerpool_requesthandleravgidlepercent_total
-    type: GAUGE
-
-  - pattern: kafka.server<type=socket-server-metrics, clientSoftwareName=(.+), clientSoftwareVersion=(.+), listener=(.+), networkProcessor=(.+)><>connections
-    name: kafka_server_socketservermetrics_connections
-    type: GAUGE
-    labels:
-      client_software_name: '$1'
-      client_software_version: '$2'
-      listener: '$3'
-      network_processor: '$4'
-
-  - pattern: 'kafka.server<type=socket-server-metrics, listener=(.+), networkProcessor=(.+)><>(.+):'
-    name: kafka_server_socketservermetrics_$3
-    type: GAUGE
-    labels:
-      listener: '$1'
-      network_processor: '$2'
-
-  # Count and Value
-  - pattern: kafka.(.*)<type=(.+), name=(.+), (.+)=(.+), (.+)=(.+)><>(Count|Value)
-    name: kafka_$1_$2_$3
-    labels:
-      '$4': '$5'
-      '$6': '$7'
-  - pattern: kafka.(.*)<type=(.+), name=(.+), (.+)=(.+)><>(Count|Value)
-    name: kafka_$1_$2_$3
-    labels:
-      '$4': '$5'
-  - pattern: kafka.(.*)<type=(.+), name=(.+)><>(Count|Value)
-    name: kafka_$1_$2_$3
-
-  # Percentile
-  - pattern: kafka.(.*)<type=(.+), name=(.+), (.+)=(.*), (.+)=(.+)><>(\d+)thPercentile
-    name: kafka_$1_$2_$3
-    type: GAUGE
-    labels:
-      '$4': '$5'
-      '$6': '$7'
-      quantile: '0.$8'
-  - pattern: kafka.(.*)<type=(.+), name=(.+), (.+)=(.*)><>(\d+)thPercentile
-    name: kafka_$1_$2_$3
-    type: GAUGE
-    labels:
-      '$4': '$5'
-      quantile: '0.$6'
-  - pattern: kafka.(.*)<type=(.+), name=(.+)><>(\d+)thPercentile
-    name: kafka_$1_$2_$3
-    type: GAUGE
-    labels:
-      quantile: '0.$4'
+  - pattern: ".*"

+ 4 - 4
documentation/compose/kafka-ui-arm64.yaml

@@ -15,13 +15,13 @@ services:
     environment:
     environment:
       KAFKA_CLUSTERS_0_NAME: local
       KAFKA_CLUSTERS_0_NAME: local
       KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka0:29092
       KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka0:29092
-#      KAFKA_CLUSTERS_0_METRICS_PORT: 9997
+      KAFKA_CLUSTERS_0_METRICS_PORT: 9997
       KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry0:8085
       KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry0:8085
       KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: first
       KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: first
       KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://kafka-connect0:8083
       KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://kafka-connect0:8083
-      KAFKA_CLUSTERS_1_NAME: local2
-      KAFKA_CLUSTERS_1_BOOTSTRAPSERVERS: kafka0:29092
-#      KAFKA_CLUSTERS_1_METRICS_PORT: 9997
+      DYNAMIC_CONFIG_ENABLED: 'true'  # not necessary, added for tests
+      KAFKA_CLUSTERS_0_AUDIT_TOPICAUDITENABLED: 'true'
+      KAFKA_CLUSTERS_0_AUDIT_CONSOLEAUDITENABLED: 'true'
 
 
   kafka0:
   kafka0:
     image: confluentinc/cp-kafka:7.2.1.arm64
     image: confluentinc/cp-kafka:7.2.1.arm64

+ 1 - 1
kafka-ui-api/pom.xml

@@ -327,7 +327,7 @@
             <plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-checkstyle-plugin</artifactId>
                 <artifactId>maven-checkstyle-plugin</artifactId>
-                <version>3.1.2</version>
+                <version>3.3.0</version>
                 <dependencies>
                 <dependencies>
                     <dependency>
                     <dependency>
                         <groupId>com.puppycrawl.tools</groupId>
                         <groupId>com.puppycrawl.tools</groupId>

+ 12 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java

@@ -51,6 +51,7 @@ public class ClustersProperties {
     List<Masking> masking;
     List<Masking> masking;
     Long pollingThrottleRate;
     Long pollingThrottleRate;
     TruststoreConfig ssl;
     TruststoreConfig ssl;
+    AuditProperties audit;
   }
   }
 
 
   @Data
   @Data
@@ -168,6 +169,17 @@ public class ClustersProperties {
     }
     }
   }
   }
 
 
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class AuditProperties {
+    String topic;
+    Integer auditTopicsPartitions;
+    Boolean topicAuditEnabled;
+    Boolean consoleAuditEnabled;
+    Map<String, String> auditTopicProperties;
+  }
+
   @PostConstruct
   @PostConstruct
   public void validateAndSetDefaults() {
   public void validateAndSetDefaults() {
     if (clusters != null) {
     if (clusters != null) {

+ 31 - 8
kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CorsGlobalConfiguration.java

@@ -1,18 +1,41 @@
 package com.provectus.kafka.ui.config;
 package com.provectus.kafka.ui.config;
 
 
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
 import org.springframework.web.reactive.config.CorsRegistry;
 import org.springframework.web.reactive.config.CorsRegistry;
 import org.springframework.web.reactive.config.WebFluxConfigurer;
 import org.springframework.web.reactive.config.WebFluxConfigurer;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
 
 
 @Configuration
 @Configuration
-public class CorsGlobalConfiguration implements WebFluxConfigurer {
+public class CorsGlobalConfiguration {
 
 
-  @Override
-  public void addCorsMappings(CorsRegistry registry) {
-    registry.addMapping("/**")
-        .allowedOrigins("*")
-        .allowedMethods("*")
-        .allowedHeaders("*")
-        .allowCredentials(false);
+  @Bean
+  public WebFilter corsFilter() {
+    return (final ServerWebExchange ctx, final WebFilterChain chain) -> {
+      final ServerHttpRequest request = ctx.getRequest();
+
+      final ServerHttpResponse response = ctx.getResponse();
+      final HttpHeaders headers = response.getHeaders();
+      headers.add("Access-Control-Allow-Origin", "*");
+      headers.add("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
+      headers.add("Access-Control-Max-Age", "3600");
+      headers.add("Access-Control-Allow-Headers", "Content-Type");
+
+      if (request.getMethod() == HttpMethod.OPTIONS) {
+        response.setStatusCode(HttpStatus.OK);
+        return Mono.empty();
+      }
+
+      return chain.filter(ctx);
+    };
   }
   }
+
 }
 }

+ 2 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapProperties.java

@@ -15,6 +15,8 @@ public class LdapProperties {
   private String userFilterSearchBase;
   private String userFilterSearchBase;
   private String userFilterSearchFilter;
   private String userFilterSearchFilter;
   private String groupFilterSearchBase;
   private String groupFilterSearchBase;
+  private String groupFilterSearchFilter;
+  private String groupRoleAttribute;
 
 
   @Value("${oauth2.ldap.activeDirectory:false}")
   @Value("${oauth2.ldap.activeDirectory:false}")
   private boolean isActiveDirectory;
   private boolean isActiveDirectory;

+ 25 - 10
kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapSecurityConfig.java

@@ -3,14 +3,16 @@ package com.provectus.kafka.ui.config.auth;
 import static com.provectus.kafka.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST;
 import static com.provectus.kafka.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST;
 
 
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
+import com.provectus.kafka.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor;
 import java.util.Collection;
 import java.util.Collection;
 import java.util.List;
 import java.util.List;
-import javax.annotation.Nullable;
+import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration;
 import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 import org.springframework.context.annotation.Import;
@@ -50,9 +52,9 @@ public class LdapSecurityConfig {
 
 
   @Bean
   @Bean
   public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource,
   public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource,
-                                                             LdapAuthoritiesPopulator ldapAuthoritiesPopulator,
-                                                             @Nullable AccessControlService acs) {
-    var rbacEnabled = acs != null && acs.isRbacEnabled();
+                                                             LdapAuthoritiesPopulator authoritiesExtractor,
+                                                             AccessControlService acs) {
+    var rbacEnabled = acs.isRbacEnabled();
     BindAuthenticator ba = new BindAuthenticator(contextSource);
     BindAuthenticator ba = new BindAuthenticator(contextSource);
     if (props.getBase() != null) {
     if (props.getBase() != null) {
       ba.setUserDnPatterns(new String[] {props.getBase()});
       ba.setUserDnPatterns(new String[] {props.getBase()});
@@ -67,7 +69,7 @@ public class LdapSecurityConfig {
     AbstractLdapAuthenticationProvider authenticationProvider;
     AbstractLdapAuthenticationProvider authenticationProvider;
     if (!props.isActiveDirectory()) {
     if (!props.isActiveDirectory()) {
       authenticationProvider = rbacEnabled
       authenticationProvider = rbacEnabled
-          ? new LdapAuthenticationProvider(ba, ldapAuthoritiesPopulator)
+          ? new LdapAuthenticationProvider(ba, authoritiesExtractor)
           : new LdapAuthenticationProvider(ba);
           : new LdapAuthenticationProvider(ba);
     } else {
     } else {
       authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(),
       authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(),
@@ -97,11 +99,24 @@ public class LdapSecurityConfig {
 
 
   @Bean
   @Bean
   @Primary
   @Primary
-  public LdapAuthoritiesPopulator ldapAuthoritiesPopulator(BaseLdapPathContextSource contextSource) {
-    var authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource, props.getGroupFilterSearchBase());
-    authoritiesPopulator.setRolePrefix("");
-    authoritiesPopulator.setConvertToUpperCase(false);
-    return authoritiesPopulator;
+  public DefaultLdapAuthoritiesPopulator ldapAuthoritiesExtractor(ApplicationContext context,
+                                                                  BaseLdapPathContextSource contextSource,
+                                                                  AccessControlService acs) {
+    var rbacEnabled = acs != null && acs.isRbacEnabled();
+
+    DefaultLdapAuthoritiesPopulator extractor;
+
+    if (rbacEnabled) {
+      extractor = new RbacLdapAuthoritiesExtractor(context, contextSource, props.getGroupFilterSearchBase());
+    } else {
+      extractor = new DefaultLdapAuthoritiesPopulator(contextSource, props.getGroupFilterSearchBase());
+    }
+
+    Optional.ofNullable(props.getGroupFilterSearchFilter()).ifPresent(extractor::setGroupSearchFilter);
+    extractor.setRolePrefix("");
+    extractor.setConvertToUpperCase(false);
+    extractor.setSearchSubtree(true);
+    return extractor;
   }
   }
 
 
   @Bean
   @Bean

+ 12 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/AclsController.java

@@ -8,6 +8,7 @@ import com.provectus.kafka.ui.model.KafkaAclResourceTypeDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.permission.AclAction;
 import com.provectus.kafka.ui.model.rbac.permission.AclAction;
 import com.provectus.kafka.ui.service.acl.AclsService;
 import com.provectus.kafka.ui.service.acl.AclsService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.Optional;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
@@ -26,6 +27,7 @@ public class AclsController extends AbstractController implements AclsApi {
 
 
   private final AclsService aclsService;
   private final AclsService aclsService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> createAcl(String clusterName, Mono<KafkaAclDTO> kafkaAclDto,
   public Mono<ResponseEntity<Void>> createAcl(String clusterName, Mono<KafkaAclDTO> kafkaAclDto,
@@ -33,12 +35,14 @@ public class AclsController extends AbstractController implements AclsApi {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .aclActions(AclAction.EDIT)
         .aclActions(AclAction.EDIT)
+        .operationName("createAcl")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context)
     return accessControlService.validateAccess(context)
         .then(kafkaAclDto)
         .then(kafkaAclDto)
         .map(ClusterMapper::toAclBinding)
         .map(ClusterMapper::toAclBinding)
         .flatMap(binding -> aclsService.createAcl(getCluster(clusterName), binding))
         .flatMap(binding -> aclsService.createAcl(getCluster(clusterName), binding))
+        .doOnEach(sig -> auditService.audit(context, sig))
         .thenReturn(ResponseEntity.ok().build());
         .thenReturn(ResponseEntity.ok().build());
   }
   }
 
 
@@ -48,12 +52,14 @@ public class AclsController extends AbstractController implements AclsApi {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .aclActions(AclAction.EDIT)
         .aclActions(AclAction.EDIT)
+        .operationName("deleteAcl")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context)
     return accessControlService.validateAccess(context)
         .then(kafkaAclDto)
         .then(kafkaAclDto)
         .map(ClusterMapper::toAclBinding)
         .map(ClusterMapper::toAclBinding)
         .flatMap(binding -> aclsService.deleteAcl(getCluster(clusterName), binding))
         .flatMap(binding -> aclsService.deleteAcl(getCluster(clusterName), binding))
+        .doOnEach(sig -> auditService.audit(context, sig))
         .thenReturn(ResponseEntity.ok().build());
         .thenReturn(ResponseEntity.ok().build());
   }
   }
 
 
@@ -66,6 +72,7 @@ public class AclsController extends AbstractController implements AclsApi {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .aclActions(AclAction.VIEW)
         .aclActions(AclAction.VIEW)
+        .operationName("listAcls")
         .build();
         .build();
 
 
     var resourceType = Optional.ofNullable(resourceTypeDto)
     var resourceType = Optional.ofNullable(resourceTypeDto)
@@ -83,7 +90,7 @@ public class AclsController extends AbstractController implements AclsApi {
             ResponseEntity.ok(
             ResponseEntity.ok(
                 aclsService.listAcls(getCluster(clusterName), filter)
                 aclsService.listAcls(getCluster(clusterName), filter)
                     .map(ClusterMapper::toKafkaAclDto)))
                     .map(ClusterMapper::toKafkaAclDto)))
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -91,12 +98,14 @@ public class AclsController extends AbstractController implements AclsApi {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .aclActions(AclAction.VIEW)
         .aclActions(AclAction.VIEW)
+        .operationName("getAclAsCsv")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context).then(
     return accessControlService.validateAccess(context).then(
         aclsService.getAclAsCsvString(getCluster(clusterName))
         aclsService.getAclAsCsvString(getCluster(clusterName))
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
             .flatMap(Mono::just)
             .flatMap(Mono::just)
+            .doOnEach(sig -> auditService.audit(context, sig))
     );
     );
   }
   }
 
 
@@ -105,11 +114,13 @@ public class AclsController extends AbstractController implements AclsApi {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .aclActions(AclAction.EDIT)
         .aclActions(AclAction.EDIT)
+        .operationName("syncAclsCsv")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context)
     return accessControlService.validateAccess(context)
         .then(csvMono)
         .then(csvMono)
         .flatMap(csv -> aclsService.syncAclWithAclCsv(getCluster(clusterName), csv))
         .flatMap(csv -> aclsService.syncAclWithAclCsv(getCluster(clusterName), csv))
+        .doOnEach(sig -> auditService.audit(context, sig))
         .thenReturn(ResponseEntity.ok().build());
         .thenReturn(ResponseEntity.ok().build());
   }
   }
 }
 }

+ 32 - 24
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/ApplicationConfigController.java

@@ -15,6 +15,7 @@ import com.provectus.kafka.ui.model.UploadedFileInfoDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.service.ApplicationInfoService;
 import com.provectus.kafka.ui.service.ApplicationInfoService;
 import com.provectus.kafka.ui.service.KafkaClusterFactory;
 import com.provectus.kafka.ui.service.KafkaClusterFactory;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.util.ApplicationRestarter;
 import com.provectus.kafka.ui.util.ApplicationRestarter;
 import com.provectus.kafka.ui.util.DynamicConfigOperations;
 import com.provectus.kafka.ui.util.DynamicConfigOperations;
@@ -55,6 +56,7 @@ public class ApplicationConfigController implements ApplicationConfigApi {
   private final ApplicationRestarter restarter;
   private final ApplicationRestarter restarter;
   private final KafkaClusterFactory kafkaClusterFactory;
   private final KafkaClusterFactory kafkaClusterFactory;
   private final ApplicationInfoService applicationInfoService;
   private final ApplicationInfoService applicationInfoService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<ApplicationInfoDTO>> getApplicationInfo(ServerWebExchange exchange) {
   public Mono<ResponseEntity<ApplicationInfoDTO>> getApplicationInfo(ServerWebExchange exchange) {
@@ -63,62 +65,68 @@ public class ApplicationConfigController implements ApplicationConfigApi {
 
 
   @Override
   @Override
   public Mono<ResponseEntity<ApplicationConfigDTO>> getCurrentConfig(ServerWebExchange exchange) {
   public Mono<ResponseEntity<ApplicationConfigDTO>> getCurrentConfig(ServerWebExchange exchange) {
-    return accessControlService
-        .validateAccess(
-            AccessContext.builder()
-                .applicationConfigActions(VIEW)
-                .build()
-        )
+    var context = AccessContext.builder()
+        .applicationConfigActions(VIEW)
+        .operationName("getCurrentConfig")
+        .build();
+    return accessControlService.validateAccess(context)
         .then(Mono.fromSupplier(() -> ResponseEntity.ok(
         .then(Mono.fromSupplier(() -> ResponseEntity.ok(
             new ApplicationConfigDTO()
             new ApplicationConfigDTO()
                 .properties(MAPPER.toDto(dynamicConfigOperations.getCurrentProperties()))
                 .properties(MAPPER.toDto(dynamicConfigOperations.getCurrentProperties()))
-        )));
+        )))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> restartWithConfig(Mono<RestartRequestDTO> restartRequestDto,
   public Mono<ResponseEntity<Void>> restartWithConfig(Mono<RestartRequestDTO> restartRequestDto,
                                                       ServerWebExchange exchange) {
                                                       ServerWebExchange exchange) {
-    return accessControlService
-        .validateAccess(
-            AccessContext.builder()
-                .applicationConfigActions(EDIT)
-                .build()
-        )
+    var context =  AccessContext.builder()
+        .applicationConfigActions(EDIT)
+        .operationName("restartWithConfig")
+        .build();
+    return accessControlService.validateAccess(context)
         .then(restartRequestDto)
         .then(restartRequestDto)
-        .map(dto -> {
+        .<ResponseEntity<Void>>map(dto -> {
           dynamicConfigOperations.persist(MAPPER.fromDto(dto.getConfig().getProperties()));
           dynamicConfigOperations.persist(MAPPER.fromDto(dto.getConfig().getProperties()));
           restarter.requestRestart();
           restarter.requestRestart();
           return ResponseEntity.ok().build();
           return ResponseEntity.ok().build();
-        });
+        })
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<UploadedFileInfoDTO>> uploadConfigRelatedFile(Flux<Part> fileFlux,
   public Mono<ResponseEntity<UploadedFileInfoDTO>> uploadConfigRelatedFile(Flux<Part> fileFlux,
                                                                            ServerWebExchange exchange) {
                                                                            ServerWebExchange exchange) {
-    return accessControlService
-        .validateAccess(
-            AccessContext.builder()
-                .applicationConfigActions(EDIT)
-                .build()
-        )
+    var context = AccessContext.builder()
+        .applicationConfigActions(EDIT)
+        .operationName("uploadConfigRelatedFile")
+        .build();
+    return accessControlService.validateAccess(context)
         .then(fileFlux.single())
         .then(fileFlux.single())
         .flatMap(file ->
         .flatMap(file ->
             dynamicConfigOperations.uploadConfigRelatedFile((FilePart) file)
             dynamicConfigOperations.uploadConfigRelatedFile((FilePart) file)
                 .map(path -> new UploadedFileInfoDTO().location(path.toString()))
                 .map(path -> new UploadedFileInfoDTO().location(path.toString()))
-                .map(ResponseEntity::ok));
+                .map(ResponseEntity::ok))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<ApplicationConfigValidationDTO>> validateConfig(Mono<ApplicationConfigDTO> configDto,
   public Mono<ResponseEntity<ApplicationConfigValidationDTO>> validateConfig(Mono<ApplicationConfigDTO> configDto,
                                                                              ServerWebExchange exchange) {
                                                                              ServerWebExchange exchange) {
-    return configDto
+    var context = AccessContext.builder()
+        .applicationConfigActions(EDIT)
+        .operationName("validateConfig")
+        .build();
+    return accessControlService.validateAccess(context)
+        .then(configDto)
         .flatMap(config -> {
         .flatMap(config -> {
           PropertiesStructure propertiesStructure = MAPPER.fromDto(config.getProperties());
           PropertiesStructure propertiesStructure = MAPPER.fromDto(config.getProperties());
           ClustersProperties clustersProperties = propertiesStructure.getKafka();
           ClustersProperties clustersProperties = propertiesStructure.getKafka();
           return validateClustersConfig(clustersProperties)
           return validateClustersConfig(clustersProperties)
               .map(validations -> new ApplicationConfigValidationDTO().clusters(validations));
               .map(validations -> new ApplicationConfigValidationDTO().clusters(validations));
         })
         })
-        .map(ResponseEntity::ok);
+        .map(ResponseEntity::ok)
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   private Mono<Map<String, ClusterConfigValidationDTO>> validateClustersConfig(
   private Mono<Map<String, ClusterConfigValidationDTO>> validateClustersConfig(

+ 53 - 30
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java

@@ -11,9 +11,11 @@ import com.provectus.kafka.ui.model.BrokersLogdirsDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
 import com.provectus.kafka.ui.service.BrokerService;
 import com.provectus.kafka.ui.service.BrokerService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
-import io.prometheus.client.Collector;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.ResponseEntity;
@@ -28,61 +30,78 @@ import reactor.core.publisher.Mono;
 public class BrokersController extends AbstractController implements BrokersApi {
 public class BrokersController extends AbstractController implements BrokersApi {
   private final BrokerService brokerService;
   private final BrokerService brokerService;
   private final ClusterMapper clusterMapper;
   private final ClusterMapper clusterMapper;
+
+  private final AuditService auditService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<BrokerDTO>>> getBrokers(String clusterName,
   public Mono<ResponseEntity<Flux<BrokerDTO>>> getBrokers(String clusterName,
                                                           ServerWebExchange exchange) {
                                                           ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
-        .build());
+        .operationName("getBrokers")
+        .build();
 
 
     var job = brokerService.getBrokers(getCluster(clusterName)).map(clusterMapper::toBrokerDto);
     var job = brokerService.getBrokers(getCluster(clusterName)).map(clusterMapper::toBrokerDto);
-
-    return validateAccess.thenReturn(ResponseEntity.ok(job));
+    return accessControlService.validateAccess(context)
+        .thenReturn(ResponseEntity.ok(job))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<BrokerMetricsDTO>> getBrokersMetrics(String clusterName, Integer id,
   public Mono<ResponseEntity<BrokerMetricsDTO>> getBrokersMetrics(String clusterName, Integer id,
                                                                   ServerWebExchange exchange) {
                                                                   ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
-        .build());
+        .operationName("getBrokersMetrics")
+        .operationParams(Map.of("id", id))
+        .build();
 
 
-    return validateAccess.then(
-        brokerService.getBrokerMetrics(getCluster(clusterName), id)
-            .map(metrics -> clusterMapper.toBrokerMetrics(metrics.stream()))
-            .map(ResponseEntity::ok)
-            .onErrorReturn(ResponseEntity.notFound().build())
-    );
+    return accessControlService.validateAccess(context)
+        .then(
+            brokerService.getBrokerMetrics(getCluster(clusterName), id)
+                .map(clusterMapper::toBrokerMetrics)
+                .map(ResponseEntity::ok)
+                .onErrorReturn(ResponseEntity.notFound().build())
+        )
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<BrokersLogdirsDTO>>> getAllBrokersLogdirs(String clusterName,
   public Mono<ResponseEntity<Flux<BrokersLogdirsDTO>>> getAllBrokersLogdirs(String clusterName,
-                                                                            List<Integer> brokers,
+                                                                            @Nullable List<Integer> brokers,
                                                                             ServerWebExchange exchange) {
                                                                             ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+
+    List<Integer> brokerIds = brokers == null ? List.of() : brokers;
+
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
-        .build());
+        .operationName("getAllBrokersLogdirs")
+        .operationParams(Map.of("brokerIds", brokerIds))
+        .build();
 
 
-    return validateAccess.thenReturn(ResponseEntity.ok(
-        brokerService.getAllBrokersLogdirs(getCluster(clusterName), brokers)));
+    return accessControlService.validateAccess(context)
+        .thenReturn(ResponseEntity.ok(
+            brokerService.getAllBrokersLogdirs(getCluster(clusterName), brokerIds)))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<BrokerConfigDTO>>> getBrokerConfig(String clusterName,
   public Mono<ResponseEntity<Flux<BrokerConfigDTO>>> getBrokerConfig(String clusterName,
                                                                      Integer id,
                                                                      Integer id,
                                                                      ServerWebExchange exchange) {
                                                                      ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .clusterConfigActions(ClusterConfigAction.VIEW)
         .clusterConfigActions(ClusterConfigAction.VIEW)
-        .build());
+        .operationName("getBrokerConfig")
+        .operationParams(Map.of("brokerId", id))
+        .build();
 
 
-    return validateAccess.thenReturn(
+    return accessControlService.validateAccess(context).thenReturn(
         ResponseEntity.ok(
         ResponseEntity.ok(
             brokerService.getBrokerConfig(getCluster(clusterName), id)
             brokerService.getBrokerConfig(getCluster(clusterName), id)
                 .map(clusterMapper::toBrokerConfig))
                 .map(clusterMapper::toBrokerConfig))
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -90,16 +109,18 @@ public class BrokersController extends AbstractController implements BrokersApi
                                                                      Integer id,
                                                                      Integer id,
                                                                      Mono<BrokerLogdirUpdateDTO> brokerLogdir,
                                                                      Mono<BrokerLogdirUpdateDTO> brokerLogdir,
                                                                      ServerWebExchange exchange) {
                                                                      ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .clusterConfigActions(ClusterConfigAction.VIEW, ClusterConfigAction.EDIT)
         .clusterConfigActions(ClusterConfigAction.VIEW, ClusterConfigAction.EDIT)
-        .build());
+        .operationName("updateBrokerTopicPartitionLogDir")
+        .operationParams(Map.of("brokerId", id))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         brokerLogdir
         brokerLogdir
             .flatMap(bld -> brokerService.updateBrokerLogDir(getCluster(clusterName), id, bld))
             .flatMap(bld -> brokerService.updateBrokerLogDir(getCluster(clusterName), id, bld))
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -108,16 +129,18 @@ public class BrokersController extends AbstractController implements BrokersApi
                                                              String name,
                                                              String name,
                                                              Mono<BrokerConfigItemDTO> brokerConfig,
                                                              Mono<BrokerConfigItemDTO> brokerConfig,
                                                              ServerWebExchange exchange) {
                                                              ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .clusterConfigActions(ClusterConfigAction.VIEW, ClusterConfigAction.EDIT)
         .clusterConfigActions(ClusterConfigAction.VIEW, ClusterConfigAction.EDIT)
-        .build());
+        .operationName("updateBrokerConfigByName")
+        .operationParams(Map.of("brokerId", id))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         brokerConfig
         brokerConfig
             .flatMap(bci -> brokerService.updateBrokerConfigByName(
             .flatMap(bci -> brokerService.updateBrokerConfigByName(
                 getCluster(clusterName), id, name, bci.getValue()))
                 getCluster(clusterName), id, name, bci.getValue()))
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 }
 }

+ 11 - 5
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/ClustersController.java

@@ -6,6 +6,7 @@ import com.provectus.kafka.ui.model.ClusterMetricsDTO;
 import com.provectus.kafka.ui.model.ClusterStatsDTO;
 import com.provectus.kafka.ui.model.ClusterStatsDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.service.ClusterService;
 import com.provectus.kafka.ui.service.ClusterService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import reactor.core.publisher.Mono;
 public class ClustersController extends AbstractController implements ClustersApi {
 public class ClustersController extends AbstractController implements ClustersApi {
   private final ClusterService clusterService;
   private final ClusterService clusterService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<ClusterDTO>>> getClusters(ServerWebExchange exchange) {
   public Mono<ResponseEntity<Flux<ClusterDTO>>> getClusters(ServerWebExchange exchange) {
@@ -35,6 +37,7 @@ public class ClustersController extends AbstractController implements ClustersAp
                                                                    ServerWebExchange exchange) {
                                                                    ServerWebExchange exchange) {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
+        .operationName("getClusterMetrics")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context)
     return accessControlService.validateAccess(context)
@@ -42,7 +45,8 @@ public class ClustersController extends AbstractController implements ClustersAp
             clusterService.getClusterMetrics(getCluster(clusterName))
             clusterService.getClusterMetrics(getCluster(clusterName))
                 .map(ResponseEntity::ok)
                 .map(ResponseEntity::ok)
                 .onErrorReturn(ResponseEntity.notFound().build())
                 .onErrorReturn(ResponseEntity.notFound().build())
-        );
+        )
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -50,6 +54,7 @@ public class ClustersController extends AbstractController implements ClustersAp
                                                                ServerWebExchange exchange) {
                                                                ServerWebExchange exchange) {
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
+        .operationName("getClusterStats")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context)
     return accessControlService.validateAccess(context)
@@ -57,7 +62,8 @@ public class ClustersController extends AbstractController implements ClustersAp
             clusterService.getClusterStats(getCluster(clusterName))
             clusterService.getClusterStats(getCluster(clusterName))
                 .map(ResponseEntity::ok)
                 .map(ResponseEntity::ok)
                 .onErrorReturn(ResponseEntity.notFound().build())
                 .onErrorReturn(ResponseEntity.notFound().build())
-        );
+        )
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -66,11 +72,11 @@ public class ClustersController extends AbstractController implements ClustersAp
 
 
     AccessContext context = AccessContext.builder()
     AccessContext context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
+        .operationName("updateClusterInfo")
         .build();
         .build();
 
 
     return accessControlService.validateAccess(context)
     return accessControlService.validateAccess(context)
-        .then(
-            clusterService.updateCluster(getCluster(clusterName)).map(ResponseEntity::ok)
-        );
+        .then(clusterService.updateCluster(getCluster(clusterName)).map(ResponseEntity::ok))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 }
 }

+ 33 - 22
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/ConsumerGroupsController.java

@@ -19,6 +19,7 @@ import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.permission.TopicAction;
 import com.provectus.kafka.ui.model.rbac.permission.TopicAction;
 import com.provectus.kafka.ui.service.ConsumerGroupService;
 import com.provectus.kafka.ui.service.ConsumerGroupService;
 import com.provectus.kafka.ui.service.OffsetsResetService;
 import com.provectus.kafka.ui.service.OffsetsResetService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.Map;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Optional;
@@ -42,6 +43,7 @@ public class ConsumerGroupsController extends AbstractController implements Cons
   private final ConsumerGroupService consumerGroupService;
   private final ConsumerGroupService consumerGroupService;
   private final OffsetsResetService offsetsResetService;
   private final OffsetsResetService offsetsResetService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Value("${consumer.groups.page.size:25}")
   @Value("${consumer.groups.page.size:25}")
   private int defaultConsumerGroupsPageSize;
   private int defaultConsumerGroupsPageSize;
@@ -50,44 +52,47 @@ public class ConsumerGroupsController extends AbstractController implements Cons
   public Mono<ResponseEntity<Void>> deleteConsumerGroup(String clusterName,
   public Mono<ResponseEntity<Void>> deleteConsumerGroup(String clusterName,
                                                         String id,
                                                         String id,
                                                         ServerWebExchange exchange) {
                                                         ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .consumerGroup(id)
         .consumerGroup(id)
         .consumerGroupActions(DELETE)
         .consumerGroupActions(DELETE)
-        .build());
+        .operationName("deleteConsumerGroup")
+        .build();
 
 
-    return validateAccess.then(
-        consumerGroupService.deleteConsumerGroupById(getCluster(clusterName), id)
-            .thenReturn(ResponseEntity.ok().build())
-    );
+    return accessControlService.validateAccess(context)
+        .then(consumerGroupService.deleteConsumerGroupById(getCluster(clusterName), id))
+        .doOnEach(sig -> auditService.audit(context, sig))
+        .thenReturn(ResponseEntity.ok().build());
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<ConsumerGroupDetailsDTO>> getConsumerGroup(String clusterName,
   public Mono<ResponseEntity<ConsumerGroupDetailsDTO>> getConsumerGroup(String clusterName,
                                                                         String consumerGroupId,
                                                                         String consumerGroupId,
                                                                         ServerWebExchange exchange) {
                                                                         ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .consumerGroup(consumerGroupId)
         .consumerGroup(consumerGroupId)
         .consumerGroupActions(VIEW)
         .consumerGroupActions(VIEW)
-        .build());
+        .operationName("getConsumerGroup")
+        .build();
 
 
-    return validateAccess.then(
-        consumerGroupService.getConsumerGroupDetail(getCluster(clusterName), consumerGroupId)
+    return accessControlService.validateAccess(context)
+        .then(consumerGroupService.getConsumerGroupDetail(getCluster(clusterName), consumerGroupId)
             .map(ConsumerGroupMapper::toDetailsDto)
             .map(ConsumerGroupMapper::toDetailsDto)
-            .map(ResponseEntity::ok)
-    );
+            .map(ResponseEntity::ok))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<ConsumerGroupDTO>>> getTopicConsumerGroups(String clusterName,
   public Mono<ResponseEntity<Flux<ConsumerGroupDTO>>> getTopicConsumerGroups(String clusterName,
                                                                              String topicName,
                                                                              String topicName,
                                                                              ServerWebExchange exchange) {
                                                                              ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(TopicAction.VIEW)
         .topicActions(TopicAction.VIEW)
-        .build());
+        .operationName("getTopicConsumerGroups")
+        .build();
 
 
     Mono<ResponseEntity<Flux<ConsumerGroupDTO>>> job =
     Mono<ResponseEntity<Flux<ConsumerGroupDTO>>> job =
         consumerGroupService.getConsumerGroupsForTopic(getCluster(clusterName), topicName)
         consumerGroupService.getConsumerGroupsForTopic(getCluster(clusterName), topicName)
@@ -99,7 +104,9 @@ public class ConsumerGroupsController extends AbstractController implements Cons
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
             .switchIfEmpty(Mono.just(ResponseEntity.notFound().build()));
             .switchIfEmpty(Mono.just(ResponseEntity.notFound().build()));
 
 
-    return validateAccess.then(job);
+    return accessControlService.validateAccess(context)
+        .then(job)
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -112,12 +119,13 @@ public class ConsumerGroupsController extends AbstractController implements Cons
       SortOrderDTO sortOrderDto,
       SortOrderDTO sortOrderDto,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         // consumer group access validation is within the service
         // consumer group access validation is within the service
-        .build());
+        .operationName("getConsumerGroupsPage")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         consumerGroupService.getConsumerGroupsPage(
         consumerGroupService.getConsumerGroupsPage(
                 getCluster(clusterName),
                 getCluster(clusterName),
                 Optional.ofNullable(page).filter(i -> i > 0).orElse(1),
                 Optional.ofNullable(page).filter(i -> i > 0).orElse(1),
@@ -128,7 +136,7 @@ public class ConsumerGroupsController extends AbstractController implements Cons
             )
             )
             .map(this::convertPage)
             .map(this::convertPage)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -137,12 +145,13 @@ public class ConsumerGroupsController extends AbstractController implements Cons
                                                               Mono<ConsumerGroupOffsetsResetDTO> resetDto,
                                                               Mono<ConsumerGroupOffsetsResetDTO> resetDto,
                                                               ServerWebExchange exchange) {
                                                               ServerWebExchange exchange) {
     return resetDto.flatMap(reset -> {
     return resetDto.flatMap(reset -> {
-      Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+      var context = AccessContext.builder()
           .cluster(clusterName)
           .cluster(clusterName)
           .topic(reset.getTopic())
           .topic(reset.getTopic())
           .topicActions(TopicAction.VIEW)
           .topicActions(TopicAction.VIEW)
           .consumerGroupActions(RESET_OFFSETS)
           .consumerGroupActions(RESET_OFFSETS)
-          .build());
+          .operationName("resetConsumerGroupOffsets")
+          .build();
 
 
       Supplier<Mono<Void>> mono = () -> {
       Supplier<Mono<Void>> mono = () -> {
         var cluster = getCluster(clusterName);
         var cluster = getCluster(clusterName);
@@ -182,7 +191,9 @@ public class ConsumerGroupsController extends AbstractController implements Cons
         }
         }
       };
       };
 
 
-      return validateAccess.then(mono.get());
+      return accessControlService.validateAccess(context)
+          .then(mono.get())
+          .doOnEach(sig -> auditService.audit(context, sig));
     }).thenReturn(ResponseEntity.ok().build());
     }).thenReturn(ResponseEntity.ok().build());
   }
   }
 
 

+ 74 - 47
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/KafkaConnectController.java

@@ -18,6 +18,7 @@ import com.provectus.kafka.ui.model.TaskDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
 import com.provectus.kafka.ui.service.KafkaConnectService;
 import com.provectus.kafka.ui.service.KafkaConnectService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.Comparator;
 import java.util.Comparator;
 import java.util.Map;
 import java.util.Map;
@@ -37,8 +38,10 @@ import reactor.core.publisher.Mono;
 public class KafkaConnectController extends AbstractController implements KafkaConnectApi {
 public class KafkaConnectController extends AbstractController implements KafkaConnectApi {
   private static final Set<ConnectorActionDTO> RESTART_ACTIONS
   private static final Set<ConnectorActionDTO> RESTART_ACTIONS
       = Set.of(RESTART, RESTART_FAILED_TASKS, RESTART_ALL_TASKS);
       = Set.of(RESTART, RESTART_FAILED_TASKS, RESTART_ALL_TASKS);
+
   private final KafkaConnectService kafkaConnectService;
   private final KafkaConnectService kafkaConnectService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<ConnectDTO>>> getConnects(String clusterName,
   public Mono<ResponseEntity<Flux<ConnectDTO>>> getConnects(String clusterName,
@@ -54,15 +57,16 @@ public class KafkaConnectController extends AbstractController implements KafkaC
   public Mono<ResponseEntity<Flux<String>>> getConnectors(String clusterName, String connectName,
   public Mono<ResponseEntity<Flux<String>>> getConnectors(String clusterName, String connectName,
                                                           ServerWebExchange exchange) {
                                                           ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW)
         .connectActions(ConnectAction.VIEW)
-        .build());
+        .operationName("getConnectors")
+        .build();
 
 
-    return validateAccess.thenReturn(
-        ResponseEntity.ok(kafkaConnectService.getConnectorNames(getCluster(clusterName), connectName))
-    );
+    return accessControlService.validateAccess(context)
+        .thenReturn(ResponseEntity.ok(kafkaConnectService.getConnectorNames(getCluster(clusterName), connectName)))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -70,16 +74,17 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                             @Valid Mono<NewConnectorDTO> connector,
                                                             @Valid Mono<NewConnectorDTO> connector,
                                                             ServerWebExchange exchange) {
                                                             ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW, ConnectAction.CREATE)
         .connectActions(ConnectAction.VIEW, ConnectAction.CREATE)
-        .build());
+        .operationName("createConnector")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         kafkaConnectService.createConnector(getCluster(clusterName), connectName, connector)
         kafkaConnectService.createConnector(getCluster(clusterName), connectName, connector)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -87,17 +92,18 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                          String connectorName,
                                                          String connectorName,
                                                          ServerWebExchange exchange) {
                                                          ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW)
         .connectActions(ConnectAction.VIEW)
         .connector(connectorName)
         .connector(connectorName)
-        .build());
+        .operationName("getConnector")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         kafkaConnectService.getConnector(getCluster(clusterName), connectName, connectorName)
         kafkaConnectService.getConnector(getCluster(clusterName), connectName, connectorName)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -105,16 +111,18 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                     String connectorName,
                                                     String connectorName,
                                                     ServerWebExchange exchange) {
                                                     ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW, ConnectAction.EDIT)
         .connectActions(ConnectAction.VIEW, ConnectAction.EDIT)
-        .build());
+        .operationName("deleteConnector")
+        .operationParams(Map.of("connectorName", connectName))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         kafkaConnectService.deleteConnector(getCluster(clusterName), connectName, connectorName)
         kafkaConnectService.deleteConnector(getCluster(clusterName), connectName, connectorName)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
 
 
@@ -126,14 +134,23 @@ public class KafkaConnectController extends AbstractController implements KafkaC
       SortOrderDTO sortOrder,
       SortOrderDTO sortOrder,
       ServerWebExchange exchange
       ServerWebExchange exchange
   ) {
   ) {
+    var context = AccessContext.builder()
+        .cluster(clusterName)
+        .connectActions(ConnectAction.VIEW, ConnectAction.EDIT)
+        .operationName("getAllConnectors")
+        .build();
+
     var comparator = sortOrder == null || sortOrder.equals(SortOrderDTO.ASC)
     var comparator = sortOrder == null || sortOrder.equals(SortOrderDTO.ASC)
         ? getConnectorsComparator(orderBy)
         ? getConnectorsComparator(orderBy)
         : getConnectorsComparator(orderBy).reversed();
         : getConnectorsComparator(orderBy).reversed();
+
     Flux<FullConnectorInfoDTO> job = kafkaConnectService.getAllConnectors(getCluster(clusterName), search)
     Flux<FullConnectorInfoDTO> job = kafkaConnectService.getAllConnectors(getCluster(clusterName), search)
         .filterWhen(dto -> accessControlService.isConnectAccessible(dto.getConnect(), clusterName))
         .filterWhen(dto -> accessControlService.isConnectAccessible(dto.getConnect(), clusterName))
-        .filterWhen(dto -> accessControlService.isConnectorAccessible(dto.getConnect(), dto.getName(), clusterName));
+        .filterWhen(dto -> accessControlService.isConnectorAccessible(dto.getConnect(), dto.getName(), clusterName))
+        .sort(comparator);
 
 
-    return Mono.just(ResponseEntity.ok(job.sort(comparator)));
+    return Mono.just(ResponseEntity.ok(job))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -142,17 +159,18 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                                       String connectorName,
                                                                       String connectorName,
                                                                       ServerWebExchange exchange) {
                                                                       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW)
         .connectActions(ConnectAction.VIEW)
-        .build());
+        .operationName("getConnectorConfig")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         kafkaConnectService
         kafkaConnectService
             .getConnectorConfig(getCluster(clusterName), connectName, connectorName)
             .getConnectorConfig(getCluster(clusterName), connectName, connectorName)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -161,16 +179,19 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                                Mono<Map<String, Object>> requestBody,
                                                                Mono<Map<String, Object>> requestBody,
                                                                ServerWebExchange exchange) {
                                                                ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW, ConnectAction.EDIT)
         .connectActions(ConnectAction.VIEW, ConnectAction.EDIT)
-        .build());
-
-    return validateAccess.then(
-        kafkaConnectService
-            .setConnectorConfig(getCluster(clusterName), connectName, connectorName, requestBody)
-            .map(ResponseEntity::ok));
+        .operationName("setConnectorConfig")
+        .operationParams(Map.of("connectorName", connectorName))
+        .build();
+
+    return accessControlService.validateAccess(context).then(
+            kafkaConnectService
+                .setConnectorConfig(getCluster(clusterName), connectName, connectorName, requestBody)
+                .map(ResponseEntity::ok))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -178,7 +199,6 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                          String connectorName,
                                                          String connectorName,
                                                          ConnectorActionDTO action,
                                                          ConnectorActionDTO action,
                                                          ServerWebExchange exchange) {
                                                          ServerWebExchange exchange) {
-
     ConnectAction[] connectActions;
     ConnectAction[] connectActions;
     if (RESTART_ACTIONS.contains(action)) {
     if (RESTART_ACTIONS.contains(action)) {
       connectActions = new ConnectAction[] {ConnectAction.VIEW, ConnectAction.RESTART};
       connectActions = new ConnectAction[] {ConnectAction.VIEW, ConnectAction.RESTART};
@@ -186,17 +206,19 @@ public class KafkaConnectController extends AbstractController implements KafkaC
       connectActions = new ConnectAction[] {ConnectAction.VIEW, ConnectAction.EDIT};
       connectActions = new ConnectAction[] {ConnectAction.VIEW, ConnectAction.EDIT};
     }
     }
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(connectActions)
         .connectActions(connectActions)
-        .build());
+        .operationName("updateConnectorState")
+        .operationParams(Map.of("connectorName", connectorName))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         kafkaConnectService
         kafkaConnectService
             .updateConnectorState(getCluster(clusterName), connectName, connectorName, action)
             .updateConnectorState(getCluster(clusterName), connectName, connectorName, action)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -204,17 +226,19 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                                String connectName,
                                                                String connectName,
                                                                String connectorName,
                                                                String connectorName,
                                                                ServerWebExchange exchange) {
                                                                ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW)
         .connectActions(ConnectAction.VIEW)
-        .build());
+        .operationName("getConnectorTasks")
+        .operationParams(Map.of("connectorName", connectorName))
+        .build();
 
 
-    return validateAccess.thenReturn(
+    return accessControlService.validateAccess(context).thenReturn(
         ResponseEntity
         ResponseEntity
             .ok(kafkaConnectService
             .ok(kafkaConnectService
                 .getConnectorTasks(getCluster(clusterName), connectName, connectorName))
                 .getConnectorTasks(getCluster(clusterName), connectName, connectorName))
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -222,34 +246,37 @@ public class KafkaConnectController extends AbstractController implements KafkaC
                                                          String connectorName, Integer taskId,
                                                          String connectorName, Integer taskId,
                                                          ServerWebExchange exchange) {
                                                          ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW, ConnectAction.RESTART)
         .connectActions(ConnectAction.VIEW, ConnectAction.RESTART)
-        .build());
+        .operationName("restartConnectorTask")
+        .operationParams(Map.of("connectorName", connectorName))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         kafkaConnectService
         kafkaConnectService
             .restartConnectorTask(getCluster(clusterName), connectName, connectorName, taskId)
             .restartConnectorTask(getCluster(clusterName), connectName, connectorName, taskId)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<ConnectorPluginDTO>>> getConnectorPlugins(
   public Mono<ResponseEntity<Flux<ConnectorPluginDTO>>> getConnectorPlugins(
       String clusterName, String connectName, ServerWebExchange exchange) {
       String clusterName, String connectName, ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .connect(connectName)
         .connect(connectName)
         .connectActions(ConnectAction.VIEW)
         .connectActions(ConnectAction.VIEW)
-        .build());
+        .operationName("getConnectorPlugins")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         Mono.just(
         Mono.just(
             ResponseEntity.ok(
             ResponseEntity.ok(
                 kafkaConnectService.getConnectorPlugins(getCluster(clusterName), connectName)))
                 kafkaConnectService.getConnectorPlugins(getCluster(clusterName), connectName)))
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override

+ 37 - 25
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/KsqlController.java

@@ -9,6 +9,7 @@ import com.provectus.kafka.ui.model.KsqlTableDescriptionDTO;
 import com.provectus.kafka.ui.model.KsqlTableResponseDTO;
 import com.provectus.kafka.ui.model.KsqlTableResponseDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.permission.KsqlAction;
 import com.provectus.kafka.ui.model.rbac.permission.KsqlAction;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.ksql.KsqlServiceV2;
 import com.provectus.kafka.ui.service.ksql.KsqlServiceV2;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.List;
 import java.util.List;
@@ -29,38 +30,43 @@ public class KsqlController extends AbstractController implements KsqlApi {
 
 
   private final KsqlServiceV2 ksqlServiceV2;
   private final KsqlServiceV2 ksqlServiceV2;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<KsqlCommandV2ResponseDTO>> executeKsql(String clusterName,
   public Mono<ResponseEntity<KsqlCommandV2ResponseDTO>> executeKsql(String clusterName,
-                                                                    Mono<KsqlCommandV2DTO>
-                                                                        ksqlCommand2Dto,
+                                                                    Mono<KsqlCommandV2DTO> ksqlCmdDo,
                                                                     ServerWebExchange exchange) {
                                                                     ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
-        .cluster(clusterName)
-        .ksqlActions(KsqlAction.EXECUTE)
-        .build());
-
-    return validateAccess.then(
-        ksqlCommand2Dto.map(dto -> {
-          var id = ksqlServiceV2.registerCommand(
-              getCluster(clusterName),
-              dto.getKsql(),
-              Optional.ofNullable(dto.getStreamsProperties()).orElse(Map.of()));
-          return new KsqlCommandV2ResponseDTO().pipeId(id);
-        }).map(ResponseEntity::ok)
-    );
+    return ksqlCmdDo.flatMap(
+            command -> {
+              var context = AccessContext.builder()
+                  .cluster(clusterName)
+                  .ksqlActions(KsqlAction.EXECUTE)
+                  .operationName("executeKsql")
+                  .operationParams(command)
+                  .build();
+              return accessControlService.validateAccess(context).thenReturn(
+                      new KsqlCommandV2ResponseDTO().pipeId(
+                          ksqlServiceV2.registerCommand(
+                              getCluster(clusterName),
+                              command.getKsql(),
+                              Optional.ofNullable(command.getStreamsProperties()).orElse(Map.of()))))
+                  .doOnEach(sig -> auditService.audit(context, sig));
+            }
+        )
+        .map(ResponseEntity::ok);
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<KsqlResponseDTO>>> openKsqlResponsePipe(String clusterName,
   public Mono<ResponseEntity<Flux<KsqlResponseDTO>>> openKsqlResponsePipe(String clusterName,
                                                                           String pipeId,
                                                                           String pipeId,
                                                                           ServerWebExchange exchange) {
                                                                           ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .ksqlActions(KsqlAction.EXECUTE)
         .ksqlActions(KsqlAction.EXECUTE)
-        .build());
+        .operationName("openKsqlResponsePipe")
+        .build();
 
 
-    return validateAccess.thenReturn(
+    return accessControlService.validateAccess(context).thenReturn(
         ResponseEntity.ok(ksqlServiceV2.execute(pipeId)
         ResponseEntity.ok(ksqlServiceV2.execute(pipeId)
             .map(table -> new KsqlResponseDTO()
             .map(table -> new KsqlResponseDTO()
                 .table(
                 .table(
@@ -74,22 +80,28 @@ public class KsqlController extends AbstractController implements KsqlApi {
   @Override
   @Override
   public Mono<ResponseEntity<Flux<KsqlStreamDescriptionDTO>>> listStreams(String clusterName,
   public Mono<ResponseEntity<Flux<KsqlStreamDescriptionDTO>>> listStreams(String clusterName,
                                                                           ServerWebExchange exchange) {
                                                                           ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .ksqlActions(KsqlAction.EXECUTE)
         .ksqlActions(KsqlAction.EXECUTE)
-        .build());
+        .operationName("listStreams")
+        .build();
 
 
-    return validateAccess.thenReturn(ResponseEntity.ok(ksqlServiceV2.listStreams(getCluster(clusterName))));
+    return accessControlService.validateAccess(context)
+        .thenReturn(ResponseEntity.ok(ksqlServiceV2.listStreams(getCluster(clusterName))))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Flux<KsqlTableDescriptionDTO>>> listTables(String clusterName,
   public Mono<ResponseEntity<Flux<KsqlTableDescriptionDTO>>> listTables(String clusterName,
                                                                         ServerWebExchange exchange) {
                                                                         ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .ksqlActions(KsqlAction.EXECUTE)
         .ksqlActions(KsqlAction.EXECUTE)
-        .build());
+        .operationName("listTables")
+        .build();
 
 
-    return validateAccess.thenReturn(ResponseEntity.ok(ksqlServiceV2.listTables(getCluster(clusterName))));
+    return accessControlService.validateAccess(context)
+        .thenReturn(ResponseEntity.ok(ksqlServiceV2.listTables(getCluster(clusterName))))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 }
 }

+ 40 - 15
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/MessagesController.java

@@ -15,12 +15,16 @@ import com.provectus.kafka.ui.model.MessageFilterTypeDTO;
 import com.provectus.kafka.ui.model.SeekDirectionDTO;
 import com.provectus.kafka.ui.model.SeekDirectionDTO;
 import com.provectus.kafka.ui.model.SeekTypeDTO;
 import com.provectus.kafka.ui.model.SeekTypeDTO;
 import com.provectus.kafka.ui.model.SerdeUsageDTO;
 import com.provectus.kafka.ui.model.SerdeUsageDTO;
+import com.provectus.kafka.ui.model.SmartFilterTestExecutionDTO;
+import com.provectus.kafka.ui.model.SmartFilterTestExecutionResultDTO;
 import com.provectus.kafka.ui.model.TopicMessageEventDTO;
 import com.provectus.kafka.ui.model.TopicMessageEventDTO;
 import com.provectus.kafka.ui.model.TopicSerdeSuggestionDTO;
 import com.provectus.kafka.ui.model.TopicSerdeSuggestionDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
+import com.provectus.kafka.ui.model.rbac.permission.AuditAction;
 import com.provectus.kafka.ui.model.rbac.permission.TopicAction;
 import com.provectus.kafka.ui.model.rbac.permission.TopicAction;
 import com.provectus.kafka.ui.service.DeserializationService;
 import com.provectus.kafka.ui.service.DeserializationService;
 import com.provectus.kafka.ui.service.MessagesService;
 import com.provectus.kafka.ui.service.MessagesService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
@@ -46,25 +50,34 @@ public class MessagesController extends AbstractController implements MessagesAp
   private final MessagesService messagesService;
   private final MessagesService messagesService;
   private final DeserializationService deserializationService;
   private final DeserializationService deserializationService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> deleteTopicMessages(
   public Mono<ResponseEntity<Void>> deleteTopicMessages(
       String clusterName, String topicName, @Valid List<Integer> partitions,
       String clusterName, String topicName, @Valid List<Integer> partitions,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(MESSAGES_DELETE)
         .topicActions(MESSAGES_DELETE)
-        .build());
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).<ResponseEntity<Void>>then(
         messagesService.deleteTopicMessages(
         messagesService.deleteTopicMessages(
             getCluster(clusterName),
             getCluster(clusterName),
             topicName,
             topicName,
             Optional.ofNullable(partitions).orElse(List.of())
             Optional.ofNullable(partitions).orElse(List.of())
         ).thenReturn(ResponseEntity.ok().build())
         ).thenReturn(ResponseEntity.ok().build())
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
+  }
+
+  @Override
+  public Mono<ResponseEntity<SmartFilterTestExecutionResultDTO>> executeSmartFilterTest(
+      Mono<SmartFilterTestExecutionDTO> smartFilterTestExecutionDto, ServerWebExchange exchange) {
+    return smartFilterTestExecutionDto
+        .map(MessagesService::execSmartFilterTest)
+        .map(ResponseEntity::ok);
   }
   }
 
 
   @Override
   @Override
@@ -79,11 +92,15 @@ public class MessagesController extends AbstractController implements MessagesAp
                                                                            String keySerde,
                                                                            String keySerde,
                                                                            String valueSerde,
                                                                            String valueSerde,
                                                                            ServerWebExchange exchange) {
                                                                            ServerWebExchange exchange) {
-    final Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var contextBuilder = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(MESSAGES_READ)
         .topicActions(MESSAGES_READ)
-        .build());
+        .operationName("getTopicMessages");
+
+    if (auditService.isAuditTopic(getCluster(clusterName), topicName)) {
+      contextBuilder.auditActions(AuditAction.VIEW);
+    }
 
 
     seekType = seekType != null ? seekType : SeekTypeDTO.BEGINNING;
     seekType = seekType != null ? seekType : SeekTypeDTO.BEGINNING;
     seekDirection = seekDirection != null ? seekDirection : SeekDirectionDTO.FORWARD;
     seekDirection = seekDirection != null ? seekDirection : SeekDirectionDTO.FORWARD;
@@ -102,7 +119,10 @@ public class MessagesController extends AbstractController implements MessagesAp
         )
         )
     );
     );
 
 
-    return validateAccess.then(job);
+    var context = contextBuilder.build();
+    return accessControlService.validateAccess(context)
+        .then(job)
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -110,17 +130,18 @@ public class MessagesController extends AbstractController implements MessagesAp
       String clusterName, String topicName, @Valid Mono<CreateTopicMessageDTO> createTopicMessage,
       String clusterName, String topicName, @Valid Mono<CreateTopicMessageDTO> createTopicMessage,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(MESSAGES_PRODUCE)
         .topicActions(MESSAGES_PRODUCE)
-        .build());
+        .operationName("sendTopicMessages")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         createTopicMessage.flatMap(msg ->
         createTopicMessage.flatMap(msg ->
             messagesService.sendMessage(getCluster(clusterName), topicName, msg).then()
             messagesService.sendMessage(getCluster(clusterName), topicName, msg).then()
         ).map(ResponseEntity::ok)
         ).map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   /**
   /**
@@ -156,12 +177,12 @@ public class MessagesController extends AbstractController implements MessagesAp
                                                                  String topicName,
                                                                  String topicName,
                                                                  SerdeUsageDTO use,
                                                                  SerdeUsageDTO use,
                                                                  ServerWebExchange exchange) {
                                                                  ServerWebExchange exchange) {
-
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(TopicAction.VIEW)
         .topicActions(TopicAction.VIEW)
-        .build());
+        .operationName("getSerdes")
+        .build();
 
 
     TopicSerdeSuggestionDTO dto = new TopicSerdeSuggestionDTO()
     TopicSerdeSuggestionDTO dto = new TopicSerdeSuggestionDTO()
         .key(use == SerdeUsageDTO.SERIALIZE
         .key(use == SerdeUsageDTO.SERIALIZE
@@ -171,10 +192,14 @@ public class MessagesController extends AbstractController implements MessagesAp
             ? deserializationService.getSerdesForSerialize(getCluster(clusterName), topicName, VALUE)
             ? deserializationService.getSerdesForSerialize(getCluster(clusterName), topicName, VALUE)
             : deserializationService.getSerdesForDeserialize(getCluster(clusterName), topicName, VALUE));
             : deserializationService.getSerdesForDeserialize(getCluster(clusterName), topicName, VALUE));
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         Mono.just(dto)
         Mono.just(dto)
             .subscribeOn(Schedulers.boundedElastic())
             .subscribeOn(Schedulers.boundedElastic())
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
     );
     );
   }
   }
+
+
+
+
 }
 }

+ 64 - 35
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java

@@ -13,8 +13,10 @@ import com.provectus.kafka.ui.model.SchemaSubjectsResponseDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.permission.SchemaAction;
 import com.provectus.kafka.ui.model.rbac.permission.SchemaAction;
 import com.provectus.kafka.ui.service.SchemaRegistryService;
 import com.provectus.kafka.ui.service.SchemaRegistryService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import javax.validation.Valid;
 import javax.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
@@ -37,6 +39,7 @@ public class SchemasController extends AbstractController implements SchemasApi
 
 
   private final SchemaRegistryService schemaRegistryService;
   private final SchemaRegistryService schemaRegistryService;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   protected KafkaCluster getCluster(String clusterName) {
   protected KafkaCluster getCluster(String clusterName) {
@@ -51,13 +54,14 @@ public class SchemasController extends AbstractController implements SchemasApi
   public Mono<ResponseEntity<CompatibilityCheckResponseDTO>> checkSchemaCompatibility(
   public Mono<ResponseEntity<CompatibilityCheckResponseDTO>> checkSchemaCompatibility(
       String clusterName, String subject, @Valid Mono<NewSchemaSubjectDTO> newSchemaSubjectMono,
       String clusterName, String subject, @Valid Mono<NewSchemaSubjectDTO> newSchemaSubjectMono,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subject)
         .schema(subject)
         .schemaActions(SchemaAction.VIEW)
         .schemaActions(SchemaAction.VIEW)
-        .build());
+        .operationName("checkSchemaCompatibility")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         newSchemaSubjectMono.flatMap(subjectDTO ->
         newSchemaSubjectMono.flatMap(subjectDTO ->
                 schemaRegistryService.checksSchemaCompatibility(
                 schemaRegistryService.checksSchemaCompatibility(
                     getCluster(clusterName),
                     getCluster(clusterName),
@@ -66,19 +70,20 @@ public class SchemasController extends AbstractController implements SchemasApi
                 ))
                 ))
             .map(kafkaSrMapper::toDto)
             .map(kafkaSrMapper::toDto)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<SchemaSubjectDTO>> createNewSchema(
   public Mono<ResponseEntity<SchemaSubjectDTO>> createNewSchema(
       String clusterName, @Valid Mono<NewSchemaSubjectDTO> newSchemaSubjectMono,
       String clusterName, @Valid Mono<NewSchemaSubjectDTO> newSchemaSubjectMono,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schemaActions(SchemaAction.CREATE)
         .schemaActions(SchemaAction.CREATE)
-        .build());
+        .operationName("createNewSchema")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         newSchemaSubjectMono.flatMap(newSubject ->
         newSchemaSubjectMono.flatMap(newSubject ->
                 schemaRegistryService.registerNewSchema(
                 schemaRegistryService.registerNewSchema(
                     getCluster(clusterName),
                     getCluster(clusterName),
@@ -87,20 +92,22 @@ public class SchemasController extends AbstractController implements SchemasApi
                 )
                 )
             ).map(kafkaSrMapper::toDto)
             ).map(kafkaSrMapper::toDto)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> deleteLatestSchema(
   public Mono<ResponseEntity<Void>> deleteLatestSchema(
       String clusterName, String subject, ServerWebExchange exchange) {
       String clusterName, String subject, ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subject)
         .schema(subject)
         .schemaActions(SchemaAction.DELETE)
         .schemaActions(SchemaAction.DELETE)
-        .build());
+        .operationName("deleteLatestSchema")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         schemaRegistryService.deleteLatestSchemaSubject(getCluster(clusterName), subject)
         schemaRegistryService.deleteLatestSchemaSubject(getCluster(clusterName), subject)
+            .doOnEach(sig -> auditService.audit(context, sig))
             .thenReturn(ResponseEntity.ok().build())
             .thenReturn(ResponseEntity.ok().build())
     );
     );
   }
   }
@@ -108,14 +115,16 @@ public class SchemasController extends AbstractController implements SchemasApi
   @Override
   @Override
   public Mono<ResponseEntity<Void>> deleteSchema(
   public Mono<ResponseEntity<Void>> deleteSchema(
       String clusterName, String subject, ServerWebExchange exchange) {
       String clusterName, String subject, ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subject)
         .schema(subject)
         .schemaActions(SchemaAction.DELETE)
         .schemaActions(SchemaAction.DELETE)
-        .build());
+        .operationName("deleteSchema")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         schemaRegistryService.deleteSchemaSubjectEntirely(getCluster(clusterName), subject)
         schemaRegistryService.deleteSchemaSubjectEntirely(getCluster(clusterName), subject)
+            .doOnEach(sig -> auditService.audit(context, sig))
             .thenReturn(ResponseEntity.ok().build())
             .thenReturn(ResponseEntity.ok().build())
     );
     );
   }
   }
@@ -123,14 +132,16 @@ public class SchemasController extends AbstractController implements SchemasApi
   @Override
   @Override
   public Mono<ResponseEntity<Void>> deleteSchemaByVersion(
   public Mono<ResponseEntity<Void>> deleteSchemaByVersion(
       String clusterName, String subjectName, Integer version, ServerWebExchange exchange) {
       String clusterName, String subjectName, Integer version, ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subjectName)
         .schema(subjectName)
         .schemaActions(SchemaAction.DELETE)
         .schemaActions(SchemaAction.DELETE)
-        .build());
+        .operationName("deleteSchemaByVersion")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         schemaRegistryService.deleteSchemaSubjectByVersion(getCluster(clusterName), subjectName, version)
         schemaRegistryService.deleteSchemaSubjectByVersion(getCluster(clusterName), subjectName, version)
+            .doOnEach(sig -> auditService.audit(context, sig))
             .thenReturn(ResponseEntity.ok().build())
             .thenReturn(ResponseEntity.ok().build())
     );
     );
   }
   }
@@ -138,16 +149,20 @@ public class SchemasController extends AbstractController implements SchemasApi
   @Override
   @Override
   public Mono<ResponseEntity<Flux<SchemaSubjectDTO>>> getAllVersionsBySubject(
   public Mono<ResponseEntity<Flux<SchemaSubjectDTO>>> getAllVersionsBySubject(
       String clusterName, String subjectName, ServerWebExchange exchange) {
       String clusterName, String subjectName, ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subjectName)
         .schema(subjectName)
         .schemaActions(SchemaAction.VIEW)
         .schemaActions(SchemaAction.VIEW)
-        .build());
+        .operationName("getAllVersionsBySubject")
+        .build();
 
 
     Flux<SchemaSubjectDTO> schemas =
     Flux<SchemaSubjectDTO> schemas =
         schemaRegistryService.getAllVersionsBySubject(getCluster(clusterName), subjectName)
         schemaRegistryService.getAllVersionsBySubject(getCluster(clusterName), subjectName)
             .map(kafkaSrMapper::toDto);
             .map(kafkaSrMapper::toDto);
-    return validateAccess.thenReturn(ResponseEntity.ok(schemas));
+
+    return accessControlService.validateAccess(context)
+        .thenReturn(ResponseEntity.ok(schemas))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -163,34 +178,37 @@ public class SchemasController extends AbstractController implements SchemasApi
   public Mono<ResponseEntity<SchemaSubjectDTO>> getLatestSchema(String clusterName,
   public Mono<ResponseEntity<SchemaSubjectDTO>> getLatestSchema(String clusterName,
                                                                 String subject,
                                                                 String subject,
                                                                 ServerWebExchange exchange) {
                                                                 ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subject)
         .schema(subject)
         .schemaActions(SchemaAction.VIEW)
         .schemaActions(SchemaAction.VIEW)
-        .build());
+        .operationName("getLatestSchema")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         schemaRegistryService.getLatestSchemaVersionBySubject(getCluster(clusterName), subject)
         schemaRegistryService.getLatestSchemaVersionBySubject(getCluster(clusterName), subject)
             .map(kafkaSrMapper::toDto)
             .map(kafkaSrMapper::toDto)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<SchemaSubjectDTO>> getSchemaByVersion(
   public Mono<ResponseEntity<SchemaSubjectDTO>> getSchemaByVersion(
       String clusterName, String subject, Integer version, ServerWebExchange exchange) {
       String clusterName, String subject, Integer version, ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schema(subject)
         .schema(subject)
         .schemaActions(SchemaAction.VIEW)
         .schemaActions(SchemaAction.VIEW)
-        .build());
+        .operationName("getSchemaByVersion")
+        .operationParams(Map.of("subject", subject, "version", version))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         schemaRegistryService.getSchemaSubjectByVersion(
         schemaRegistryService.getSchemaSubjectByVersion(
                 getCluster(clusterName), subject, version)
                 getCluster(clusterName), subject, version)
             .map(kafkaSrMapper::toDto)
             .map(kafkaSrMapper::toDto)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -199,6 +217,11 @@ public class SchemasController extends AbstractController implements SchemasApi
                                                                     @Valid Integer perPage,
                                                                     @Valid Integer perPage,
                                                                     @Valid String search,
                                                                     @Valid String search,
                                                                     ServerWebExchange serverWebExchange) {
                                                                     ServerWebExchange serverWebExchange) {
+    var context = AccessContext.builder()
+        .cluster(clusterName)
+        .operationName("getSchemas")
+        .build();
+
     return schemaRegistryService
     return schemaRegistryService
         .getAllSubjectNames(getCluster(clusterName))
         .getAllSubjectNames(getCluster(clusterName))
         .flatMapIterable(l -> l)
         .flatMapIterable(l -> l)
@@ -220,25 +243,28 @@ public class SchemasController extends AbstractController implements SchemasApi
           return schemaRegistryService.getAllLatestVersionSchemas(getCluster(clusterName), subjectsToRender)
           return schemaRegistryService.getAllLatestVersionSchemas(getCluster(clusterName), subjectsToRender)
               .map(subjs -> subjs.stream().map(kafkaSrMapper::toDto).toList())
               .map(subjs -> subjs.stream().map(kafkaSrMapper::toDto).toList())
               .map(subjs -> new SchemaSubjectsResponseDTO().pageCount(totalPages).schemas(subjs));
               .map(subjs -> new SchemaSubjectsResponseDTO().pageCount(totalPages).schemas(subjs));
-        }).map(ResponseEntity::ok);
+        }).map(ResponseEntity::ok)
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> updateGlobalSchemaCompatibilityLevel(
   public Mono<ResponseEntity<Void>> updateGlobalSchemaCompatibilityLevel(
       String clusterName, @Valid Mono<CompatibilityLevelDTO> compatibilityLevelMono,
       String clusterName, @Valid Mono<CompatibilityLevelDTO> compatibilityLevelMono,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schemaActions(SchemaAction.MODIFY_GLOBAL_COMPATIBILITY)
         .schemaActions(SchemaAction.MODIFY_GLOBAL_COMPATIBILITY)
-        .build());
+        .operationName("updateGlobalSchemaCompatibilityLevel")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         compatibilityLevelMono
         compatibilityLevelMono
             .flatMap(compatibilityLevelDTO ->
             .flatMap(compatibilityLevelDTO ->
                 schemaRegistryService.updateGlobalSchemaCompatibility(
                 schemaRegistryService.updateGlobalSchemaCompatibility(
                     getCluster(clusterName),
                     getCluster(clusterName),
                     kafkaSrMapper.fromDto(compatibilityLevelDTO.getCompatibility())
                     kafkaSrMapper.fromDto(compatibilityLevelDTO.getCompatibility())
                 ))
                 ))
+            .doOnEach(sig -> auditService.audit(context, sig))
             .thenReturn(ResponseEntity.ok().build())
             .thenReturn(ResponseEntity.ok().build())
     );
     );
   }
   }
@@ -247,12 +273,14 @@ public class SchemasController extends AbstractController implements SchemasApi
   public Mono<ResponseEntity<Void>> updateSchemaCompatibilityLevel(
   public Mono<ResponseEntity<Void>> updateSchemaCompatibilityLevel(
       String clusterName, String subject, @Valid Mono<CompatibilityLevelDTO> compatibilityLevelMono,
       String clusterName, String subject, @Valid Mono<CompatibilityLevelDTO> compatibilityLevelMono,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .schemaActions(SchemaAction.EDIT)
         .schemaActions(SchemaAction.EDIT)
-        .build());
+        .operationName("updateSchemaCompatibilityLevel")
+        .operationParams(Map.of("subject", subject))
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         compatibilityLevelMono
         compatibilityLevelMono
             .flatMap(compatibilityLevelDTO ->
             .flatMap(compatibilityLevelDTO ->
                 schemaRegistryService.updateSchemaCompatibility(
                 schemaRegistryService.updateSchemaCompatibility(
@@ -260,6 +288,7 @@ public class SchemasController extends AbstractController implements SchemasApi
                     subject,
                     subject,
                     kafkaSrMapper.fromDto(compatibilityLevelDTO.getCompatibility())
                     kafkaSrMapper.fromDto(compatibilityLevelDTO.getCompatibility())
                 ))
                 ))
+            .doOnEach(sig -> auditService.audit(context, sig))
             .thenReturn(ResponseEntity.ok().build())
             .thenReturn(ResponseEntity.ok().build())
     );
     );
   }
   }

+ 89 - 63
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/TopicsController.java

@@ -27,9 +27,11 @@ import com.provectus.kafka.ui.model.TopicsResponseDTO;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.model.rbac.AccessContext;
 import com.provectus.kafka.ui.service.TopicsService;
 import com.provectus.kafka.ui.service.TopicsService;
 import com.provectus.kafka.ui.service.analyze.TopicAnalysisService;
 import com.provectus.kafka.ui.service.analyze.TopicAnalysisService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import java.util.Comparator;
 import java.util.Comparator;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import javax.validation.Valid;
 import javax.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -52,69 +54,78 @@ public class TopicsController extends AbstractController implements TopicsApi {
   private final TopicAnalysisService topicAnalysisService;
   private final TopicAnalysisService topicAnalysisService;
   private final ClusterMapper clusterMapper;
   private final ClusterMapper clusterMapper;
   private final AccessControlService accessControlService;
   private final AccessControlService accessControlService;
+  private final AuditService auditService;
 
 
   @Override
   @Override
   public Mono<ResponseEntity<TopicDTO>> createTopic(
   public Mono<ResponseEntity<TopicDTO>> createTopic(
-      String clusterName, @Valid Mono<TopicCreationDTO> topicCreation, ServerWebExchange exchange) {
-
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
-        .cluster(clusterName)
-        .topicActions(CREATE)
-        .build());
-
-    return validateAccess.then(
-        topicsService.createTopic(getCluster(clusterName), topicCreation)
-            .map(clusterMapper::toTopic)
-            .map(s -> new ResponseEntity<>(s, HttpStatus.OK))
-            .switchIfEmpty(Mono.just(ResponseEntity.notFound().build()))
-    );
+      String clusterName, @Valid Mono<TopicCreationDTO> topicCreationMono, ServerWebExchange exchange) {
+    return topicCreationMono.flatMap(topicCreation -> {
+      var context = AccessContext.builder()
+          .cluster(clusterName)
+          .topicActions(CREATE)
+          .operationName("createTopic")
+          .operationParams(topicCreation)
+          .build();
+
+      return accessControlService.validateAccess(context)
+          .then(topicsService.createTopic(getCluster(clusterName), topicCreation))
+          .map(clusterMapper::toTopic)
+          .map(s -> new ResponseEntity<>(s, HttpStatus.OK))
+          .switchIfEmpty(Mono.just(ResponseEntity.notFound().build()))
+          .doOnEach(sig -> auditService.audit(context, sig));
+    });
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<TopicDTO>> recreateTopic(String clusterName,
   public Mono<ResponseEntity<TopicDTO>> recreateTopic(String clusterName,
                                                       String topicName, ServerWebExchange exchange) {
                                                       String topicName, ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW, CREATE, DELETE)
         .topicActions(VIEW, CREATE, DELETE)
-        .build());
+        .operationName("recreateTopic")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         topicsService.recreateTopic(getCluster(clusterName), topicName)
         topicsService.recreateTopic(getCluster(clusterName), topicName)
             .map(clusterMapper::toTopic)
             .map(clusterMapper::toTopic)
             .map(s -> new ResponseEntity<>(s, HttpStatus.CREATED))
             .map(s -> new ResponseEntity<>(s, HttpStatus.CREATED))
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<TopicDTO>> cloneTopic(
   public Mono<ResponseEntity<TopicDTO>> cloneTopic(
       String clusterName, String topicName, String newTopicName, ServerWebExchange exchange) {
       String clusterName, String topicName, String newTopicName, ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW, CREATE)
         .topicActions(VIEW, CREATE)
-        .build());
+        .operationName("cloneTopic")
+        .operationParams(Map.of("newTopicName", newTopicName))
+        .build();
 
 
-    return validateAccess.then(topicsService.cloneTopic(getCluster(clusterName), topicName, newTopicName)
-        .map(clusterMapper::toTopic)
-        .map(s -> new ResponseEntity<>(s, HttpStatus.CREATED))
-    );
+    return accessControlService.validateAccess(context)
+        .then(topicsService.cloneTopic(getCluster(clusterName), topicName, newTopicName)
+            .map(clusterMapper::toTopic)
+            .map(s -> new ResponseEntity<>(s, HttpStatus.CREATED))
+        ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> deleteTopic(
   public Mono<ResponseEntity<Void>> deleteTopic(
       String clusterName, String topicName, ServerWebExchange exchange) {
       String clusterName, String topicName, ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(DELETE)
         .topicActions(DELETE)
-        .build());
+        .operationName("deleteTopic")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         topicsService.deleteTopic(getCluster(clusterName), topicName).map(ResponseEntity::ok)
         topicsService.deleteTopic(getCluster(clusterName), topicName).map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
 
 
@@ -122,13 +133,14 @@ public class TopicsController extends AbstractController implements TopicsApi {
   public Mono<ResponseEntity<Flux<TopicConfigDTO>>> getTopicConfigs(
   public Mono<ResponseEntity<Flux<TopicConfigDTO>>> getTopicConfigs(
       String clusterName, String topicName, ServerWebExchange exchange) {
       String clusterName, String topicName, ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW)
         .topicActions(VIEW)
-        .build());
+        .operationName("getTopicConfigs")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         topicsService.getTopicConfigs(getCluster(clusterName), topicName)
         topicsService.getTopicConfigs(getCluster(clusterName), topicName)
             .map(lst -> lst.stream()
             .map(lst -> lst.stream()
                 .map(InternalTopicConfig::from)
                 .map(InternalTopicConfig::from)
@@ -136,24 +148,25 @@ public class TopicsController extends AbstractController implements TopicsApi {
                 .collect(toList()))
                 .collect(toList()))
             .map(Flux::fromIterable)
             .map(Flux::fromIterable)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<TopicDetailsDTO>> getTopicDetails(
   public Mono<ResponseEntity<TopicDetailsDTO>> getTopicDetails(
       String clusterName, String topicName, ServerWebExchange exchange) {
       String clusterName, String topicName, ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW)
         .topicActions(VIEW)
-        .build());
+        .operationName("getTopicDetails")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         topicsService.getTopicDetails(getCluster(clusterName), topicName)
         topicsService.getTopicDetails(getCluster(clusterName), topicName)
             .map(clusterMapper::toTopicDetails)
             .map(clusterMapper::toTopicDetails)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -166,6 +179,11 @@ public class TopicsController extends AbstractController implements TopicsApi {
                                                            @Valid SortOrderDTO sortOrder,
                                                            @Valid SortOrderDTO sortOrder,
                                                            ServerWebExchange exchange) {
                                                            ServerWebExchange exchange) {
 
 
+    AccessContext context = AccessContext.builder()
+        .cluster(clusterName)
+        .operationName("getTopics")
+        .build();
+
     return topicsService.getTopicsForPagination(getCluster(clusterName))
     return topicsService.getTopicsForPagination(getCluster(clusterName))
         .flatMap(topics -> accessControlService.filterViewableTopics(topics, clusterName))
         .flatMap(topics -> accessControlService.filterViewableTopics(topics, clusterName))
         .flatMap(topics -> {
         .flatMap(topics -> {
@@ -189,14 +207,13 @@ public class TopicsController extends AbstractController implements TopicsApi {
               .collect(toList());
               .collect(toList());
 
 
           return topicsService.loadTopics(getCluster(clusterName), topicsPage)
           return topicsService.loadTopics(getCluster(clusterName), topicsPage)
-              .flatMapMany(Flux::fromIterable)
-              .collectList()
               .map(topicsToRender ->
               .map(topicsToRender ->
                   new TopicsResponseDTO()
                   new TopicsResponseDTO()
                       .topics(topicsToRender.stream().map(clusterMapper::toTopic).collect(toList()))
                       .topics(topicsToRender.stream().map(clusterMapper::toTopic).collect(toList()))
                       .pageCount(totalPages));
                       .pageCount(totalPages));
         })
         })
-        .map(ResponseEntity::ok);
+        .map(ResponseEntity::ok)
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -204,18 +221,19 @@ public class TopicsController extends AbstractController implements TopicsApi {
       String clusterName, String topicName, @Valid Mono<TopicUpdateDTO> topicUpdate,
       String clusterName, String topicName, @Valid Mono<TopicUpdateDTO> topicUpdate,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW, EDIT)
         .topicActions(VIEW, EDIT)
-        .build());
+        .operationName("updateTopic")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         topicsService
         topicsService
             .updateTopic(getCluster(clusterName), topicName, topicUpdate)
             .updateTopic(getCluster(clusterName), topicName, topicUpdate)
             .map(clusterMapper::toTopic)
             .map(clusterMapper::toTopic)
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -224,17 +242,17 @@ public class TopicsController extends AbstractController implements TopicsApi {
       Mono<PartitionsIncreaseDTO> partitionsIncrease,
       Mono<PartitionsIncreaseDTO> partitionsIncrease,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW, EDIT)
         .topicActions(VIEW, EDIT)
-        .build());
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         partitionsIncrease.flatMap(partitions ->
         partitionsIncrease.flatMap(partitions ->
             topicsService.increaseTopicPartitions(getCluster(clusterName), topicName, partitions)
             topicsService.increaseTopicPartitions(getCluster(clusterName), topicName, partitions)
         ).map(ResponseEntity::ok)
         ).map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
@@ -243,31 +261,34 @@ public class TopicsController extends AbstractController implements TopicsApi {
       Mono<ReplicationFactorChangeDTO> replicationFactorChange,
       Mono<ReplicationFactorChangeDTO> replicationFactorChange,
       ServerWebExchange exchange) {
       ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(VIEW, EDIT)
         .topicActions(VIEW, EDIT)
-        .build());
+        .operationName("changeReplicationFactor")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         replicationFactorChange
         replicationFactorChange
             .flatMap(rfc ->
             .flatMap(rfc ->
                 topicsService.changeReplicationFactor(getCluster(clusterName), topicName, rfc))
                 topicsService.changeReplicationFactor(getCluster(clusterName), topicName, rfc))
             .map(ResponseEntity::ok)
             .map(ResponseEntity::ok)
-    );
+    ).doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   @Override
   @Override
   public Mono<ResponseEntity<Void>> analyzeTopic(String clusterName, String topicName, ServerWebExchange exchange) {
   public Mono<ResponseEntity<Void>> analyzeTopic(String clusterName, String topicName, ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(MESSAGES_READ)
         .topicActions(MESSAGES_READ)
-        .build());
+        .operationName("analyzeTopic")
+        .build();
 
 
-    return validateAccess.then(
+    return accessControlService.validateAccess(context).then(
         topicAnalysisService.analyze(getCluster(clusterName), topicName)
         topicAnalysisService.analyze(getCluster(clusterName), topicName)
+            .doOnEach(sig -> auditService.audit(context, sig))
             .thenReturn(ResponseEntity.ok().build())
             .thenReturn(ResponseEntity.ok().build())
     );
     );
   }
   }
@@ -275,15 +296,17 @@ public class TopicsController extends AbstractController implements TopicsApi {
   @Override
   @Override
   public Mono<ResponseEntity<Void>> cancelTopicAnalysis(String clusterName, String topicName,
   public Mono<ResponseEntity<Void>> cancelTopicAnalysis(String clusterName, String topicName,
                                                         ServerWebExchange exchange) {
                                                         ServerWebExchange exchange) {
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(MESSAGES_READ)
         .topicActions(MESSAGES_READ)
-        .build());
-
-    topicAnalysisService.cancelAnalysis(getCluster(clusterName), topicName);
+        .operationName("cancelTopicAnalysis")
+        .build();
 
 
-    return validateAccess.thenReturn(ResponseEntity.ok().build());
+    return accessControlService.validateAccess(context)
+        .then(Mono.fromRunnable(() -> topicAnalysisService.cancelAnalysis(getCluster(clusterName), topicName)))
+        .doOnEach(sig -> auditService.audit(context, sig))
+        .thenReturn(ResponseEntity.ok().build());
   }
   }
 
 
 
 
@@ -292,15 +315,18 @@ public class TopicsController extends AbstractController implements TopicsApi {
                                                                  String topicName,
                                                                  String topicName,
                                                                  ServerWebExchange exchange) {
                                                                  ServerWebExchange exchange) {
 
 
-    Mono<Void> validateAccess = accessControlService.validateAccess(AccessContext.builder()
+    var context = AccessContext.builder()
         .cluster(clusterName)
         .cluster(clusterName)
         .topic(topicName)
         .topic(topicName)
         .topicActions(MESSAGES_READ)
         .topicActions(MESSAGES_READ)
-        .build());
+        .operationName("getTopicAnalysis")
+        .build();
 
 
-    return validateAccess.thenReturn(topicAnalysisService.getTopicAnalysis(getCluster(clusterName), topicName)
-        .map(ResponseEntity::ok)
-        .orElseGet(() -> ResponseEntity.notFound().build()));
+    return accessControlService.validateAccess(context)
+        .thenReturn(topicAnalysisService.getTopicAnalysis(getCluster(clusterName), topicName)
+            .map(ResponseEntity::ok)
+            .orElseGet(() -> ResponseEntity.notFound().build()))
+        .doOnEach(sig -> auditService.audit(context, sig));
   }
   }
 
 
   private Comparator<InternalTopic> getComparatorForTopic(
   private Comparator<InternalTopic> getComparatorForTopic(

+ 7 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/JsonAvroConversionException.java

@@ -0,0 +1,7 @@
+package com.provectus.kafka.ui.exception;
+
+public class JsonAvroConversionException extends ValidationException {
+  public JsonAvroConversionException(String message) {
+    super(message);
+  }
+}

+ 0 - 7
kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/JsonToAvroConversionException.java

@@ -1,7 +0,0 @@
-package com.provectus.kafka.ui.exception;
-
-public class JsonToAvroConversionException extends ValidationException {
-  public JsonToAvroConversionException(String message) {
-    super(message);
-  }
-}

+ 34 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/AccessContext.java

@@ -2,6 +2,7 @@ package com.provectus.kafka.ui.model.rbac;
 
 
 import com.provectus.kafka.ui.model.rbac.permission.AclAction;
 import com.provectus.kafka.ui.model.rbac.permission.AclAction;
 import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
+import com.provectus.kafka.ui.model.rbac.permission.AuditAction;
 import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction;
@@ -11,6 +12,7 @@ import com.provectus.kafka.ui.model.rbac.permission.TopicAction;
 import java.util.Collection;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import lombok.Value;
 import lombok.Value;
 import org.springframework.util.Assert;
 import org.springframework.util.Assert;
 
 
@@ -40,6 +42,11 @@ public class AccessContext {
 
 
   Collection<AclAction> aclActions;
   Collection<AclAction> aclActions;
 
 
+  Collection<AuditAction> auditAction;
+
+  String operationName;
+  Object operationParams;
+
   public static AccessContextBuilder builder() {
   public static AccessContextBuilder builder() {
     return new AccessContextBuilder();
     return new AccessContextBuilder();
   }
   }
@@ -59,6 +66,10 @@ public class AccessContext {
     private Collection<SchemaAction> schemaActions = Collections.emptySet();
     private Collection<SchemaAction> schemaActions = Collections.emptySet();
     private Collection<KsqlAction> ksqlActions = Collections.emptySet();
     private Collection<KsqlAction> ksqlActions = Collections.emptySet();
     private Collection<AclAction> aclActions = Collections.emptySet();
     private Collection<AclAction> aclActions = Collections.emptySet();
+    private Collection<AuditAction> auditActions = Collections.emptySet();
+
+    private String operationName;
+    private Object operationParams;
 
 
     private AccessContextBuilder() {
     private AccessContextBuilder() {
     }
     }
@@ -141,6 +152,27 @@ public class AccessContext {
       return this;
       return this;
     }
     }
 
 
+    public AccessContextBuilder auditActions(AuditAction... actions) {
+      Assert.isTrue(actions.length > 0, "actions not present");
+      this.auditActions = List.of(actions);
+      return this;
+    }
+
+    public AccessContextBuilder operationName(String operationName) {
+      this.operationName = operationName;
+      return this;
+    }
+
+    public AccessContextBuilder operationParams(Object operationParams) {
+      this.operationParams = operationParams;
+      return this;
+    }
+
+    public AccessContextBuilder operationParams(Map<String, Object> paramsMap) {
+      this.operationParams = paramsMap;
+      return this;
+    }
+
     public AccessContext build() {
     public AccessContext build() {
       return new AccessContext(
       return new AccessContext(
           applicationConfigActions,
           applicationConfigActions,
@@ -150,7 +182,8 @@ public class AccessContext {
           connect, connectActions,
           connect, connectActions,
           connector,
           connector,
           schema, schemaActions,
           schema, schemaActions,
-          ksqlActions, aclActions);
+          ksqlActions, aclActions, auditActions,
+          operationName, operationParams);
     }
     }
   }
   }
 }
 }

+ 5 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/Permission.java

@@ -2,11 +2,13 @@ package com.provectus.kafka.ui.model.rbac;
 
 
 import static com.provectus.kafka.ui.model.rbac.Resource.ACL;
 import static com.provectus.kafka.ui.model.rbac.Resource.ACL;
 import static com.provectus.kafka.ui.model.rbac.Resource.APPLICATIONCONFIG;
 import static com.provectus.kafka.ui.model.rbac.Resource.APPLICATIONCONFIG;
+import static com.provectus.kafka.ui.model.rbac.Resource.AUDIT;
 import static com.provectus.kafka.ui.model.rbac.Resource.CLUSTERCONFIG;
 import static com.provectus.kafka.ui.model.rbac.Resource.CLUSTERCONFIG;
 import static com.provectus.kafka.ui.model.rbac.Resource.KSQL;
 import static com.provectus.kafka.ui.model.rbac.Resource.KSQL;
 
 
 import com.provectus.kafka.ui.model.rbac.permission.AclAction;
 import com.provectus.kafka.ui.model.rbac.permission.AclAction;
 import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
+import com.provectus.kafka.ui.model.rbac.permission.AuditAction;
 import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction;
 import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction;
@@ -28,7 +30,8 @@ import org.springframework.util.Assert;
 @EqualsAndHashCode
 @EqualsAndHashCode
 public class Permission {
 public class Permission {
 
 
-  private static final List<Resource> RBAC_ACTION_EXEMPT_LIST = List.of(KSQL, CLUSTERCONFIG, APPLICATIONCONFIG, ACL);
+  private static final List<Resource> RBAC_ACTION_EXEMPT_LIST =
+      List.of(KSQL, CLUSTERCONFIG, APPLICATIONCONFIG, ACL, AUDIT);
 
 
   Resource resource;
   Resource resource;
   List<String> actions;
   List<String> actions;
@@ -79,6 +82,7 @@ public class Permission {
       case CONNECT -> Arrays.stream(ConnectAction.values()).map(Enum::toString).toList();
       case CONNECT -> Arrays.stream(ConnectAction.values()).map(Enum::toString).toList();
       case KSQL -> Arrays.stream(KsqlAction.values()).map(Enum::toString).toList();
       case KSQL -> Arrays.stream(KsqlAction.values()).map(Enum::toString).toList();
       case ACL -> Arrays.stream(AclAction.values()).map(Enum::toString).toList();
       case ACL -> Arrays.stream(AclAction.values()).map(Enum::toString).toList();
+      case AUDIT -> Arrays.stream(AuditAction.values()).map(Enum::toString).toList();
     };
     };
   }
   }
 
 

+ 2 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/Resource.java

@@ -12,7 +12,8 @@ public enum Resource {
   SCHEMA,
   SCHEMA,
   CONNECT,
   CONNECT,
   KSQL,
   KSQL,
-  ACL;
+  ACL,
+  AUDIT;
 
 
   @Nullable
   @Nullable
   public static Resource fromString(String name) {
   public static Resource fromString(String name) {

+ 14 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/permission/AuditAction.java

@@ -0,0 +1,14 @@
+package com.provectus.kafka.ui.model.rbac.permission;
+
+import org.apache.commons.lang3.EnumUtils;
+import org.jetbrains.annotations.Nullable;
+
+public enum AuditAction implements PermissibleAction {
+
+  VIEW;
+
+  @Nullable
+  public static AuditAction fromString(String name) {
+    return EnumUtils.getEnum(AuditAction.class, name);
+  }
+}

+ 5 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/rbac/permission/PermissibleAction.java

@@ -1,4 +1,8 @@
 package com.provectus.kafka.ui.model.rbac.permission;
 package com.provectus.kafka.ui.model.rbac.permission;
 
 
-public interface PermissibleAction {
+public sealed interface PermissibleAction permits
+    AclAction, ApplicationConfigAction,
+    ConsumerGroupAction, SchemaAction,
+    ConnectAction, ClusterConfigAction,
+    KsqlAction, TopicAction, AuditAction {
 }
 }

+ 24 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/SerdesInitializer.java

@@ -11,6 +11,7 @@ import com.provectus.kafka.ui.serde.api.PropertyResolver;
 import com.provectus.kafka.ui.serde.api.Serde;
 import com.provectus.kafka.ui.serde.api.Serde;
 import com.provectus.kafka.ui.serdes.builtin.AvroEmbeddedSerde;
 import com.provectus.kafka.ui.serdes.builtin.AvroEmbeddedSerde;
 import com.provectus.kafka.ui.serdes.builtin.Base64Serde;
 import com.provectus.kafka.ui.serdes.builtin.Base64Serde;
+import com.provectus.kafka.ui.serdes.builtin.ConsumerOffsetsSerde;
 import com.provectus.kafka.ui.serdes.builtin.Int32Serde;
 import com.provectus.kafka.ui.serdes.builtin.Int32Serde;
 import com.provectus.kafka.ui.serdes.builtin.Int64Serde;
 import com.provectus.kafka.ui.serdes.builtin.Int64Serde;
 import com.provectus.kafka.ui.serdes.builtin.ProtobufFileSerde;
 import com.provectus.kafka.ui.serdes.builtin.ProtobufFileSerde;
@@ -118,6 +119,8 @@ public class SerdesInitializer {
       }
       }
     });
     });
 
 
+    registerTopicRelatedSerde(registeredSerdes);
+
     return new ClusterSerdes(
     return new ClusterSerdes(
         registeredSerdes,
         registeredSerdes,
         Optional.ofNullable(clusterProperties.getDefaultKeySerde())
         Optional.ofNullable(clusterProperties.getDefaultKeySerde())
@@ -132,6 +135,27 @@ public class SerdesInitializer {
     );
     );
   }
   }
 
 
+  /**
+   * Registers serdse that should only be used for specific (hard-coded) topics, like ConsumerOffsetsSerde.
+   */
+  private void registerTopicRelatedSerde(Map<String, SerdeInstance> serdes) {
+    registerConsumerOffsetsSerde(serdes);
+  }
+
+  private void registerConsumerOffsetsSerde(Map<String, SerdeInstance> serdes) {
+    var pattern = Pattern.compile(ConsumerOffsetsSerde.TOPIC);
+    serdes.put(
+        ConsumerOffsetsSerde.name(),
+        new SerdeInstance(
+            ConsumerOffsetsSerde.name(),
+            new ConsumerOffsetsSerde(),
+            pattern,
+            pattern,
+            null
+        )
+    );
+  }
+
   private SerdeInstance createFallbackSerde() {
   private SerdeInstance createFallbackSerde() {
     StringSerde serde = new StringSerde();
     StringSerde serde = new StringSerde();
     serde.configure(PropertyResolverImpl.empty(), PropertyResolverImpl.empty(), PropertyResolverImpl.empty());
     serde.configure(PropertyResolverImpl.empty(), PropertyResolverImpl.empty(), PropertyResolverImpl.empty());

+ 294 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/builtin/ConsumerOffsetsSerde.java

@@ -0,0 +1,294 @@
+package com.provectus.kafka.ui.serdes.builtin;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.provectus.kafka.ui.serde.api.DeserializeResult;
+import com.provectus.kafka.ui.serde.api.SchemaDescription;
+import com.provectus.kafka.ui.serdes.BuiltInSerde;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Optional;
+import lombok.SneakyThrows;
+import org.apache.kafka.common.protocol.types.ArrayOf;
+import org.apache.kafka.common.protocol.types.BoundField;
+import org.apache.kafka.common.protocol.types.CompactArrayOf;
+import org.apache.kafka.common.protocol.types.Field;
+import org.apache.kafka.common.protocol.types.Schema;
+import org.apache.kafka.common.protocol.types.Struct;
+import org.apache.kafka.common.protocol.types.Type;
+
+// Deserialization logic and message's schemas can be found in
+// kafka.coordinator.group.GroupMetadataManager (readMessageKey, readOffsetMessageValue, readGroupMessageValue)
+public class ConsumerOffsetsSerde implements BuiltInSerde {
+
+  private static final JsonMapper JSON_MAPPER = createMapper();
+
+  public static final String TOPIC = "__consumer_offsets";
+
+  public static String name() {
+    return "__consumer_offsets";
+  }
+
+  private static JsonMapper createMapper() {
+    var module = new SimpleModule();
+    module.addSerializer(Struct.class, new JsonSerializer<>() {
+      @Override
+      public void serialize(Struct value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+        gen.writeStartObject();
+        for (BoundField field : value.schema().fields()) {
+          var fieldVal = value.get(field);
+          gen.writeObjectField(field.def.name, fieldVal);
+        }
+        gen.writeEndObject();
+      }
+    });
+    var mapper = new JsonMapper();
+    mapper.registerModule(module);
+    return mapper;
+  }
+
+  @Override
+  public Optional<String> getDescription() {
+    return Optional.empty();
+  }
+
+  @Override
+  public Optional<SchemaDescription> getSchema(String topic, Target type) {
+    return Optional.empty();
+  }
+
+  @Override
+  public boolean canDeserialize(String topic, Target type) {
+    return topic.equals(TOPIC);
+  }
+
+  @Override
+  public boolean canSerialize(String topic, Target type) {
+    return false;
+  }
+
+  @Override
+  public Serializer serializer(String topic, Target type) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Deserializer deserializer(String topic, Target type) {
+    return switch (type) {
+      case KEY -> keyDeserializer();
+      case VALUE -> valueDeserializer();
+    };
+  }
+
+  private Deserializer keyDeserializer() {
+    final Schema commitKeySchema = new Schema(
+        new Field("group", Type.STRING, ""),
+        new Field("topic", Type.STRING, ""),
+        new Field("partition", Type.INT32, "")
+    );
+
+    final Schema groupMetadataSchema = new Schema(
+        new Field("group", Type.STRING, "")
+    );
+
+    return (headers, data) -> {
+      var bb = ByteBuffer.wrap(data);
+      short version = bb.getShort();
+      return new DeserializeResult(
+          toJson(
+              switch (version) {
+                case 0, 1 -> commitKeySchema.read(bb);
+                case 2 -> groupMetadataSchema.read(bb);
+                default -> throw new IllegalStateException("Unknown group metadata message version: " + version);
+              }
+          ),
+          DeserializeResult.Type.JSON,
+          Map.of()
+      );
+    };
+  }
+
+  private Deserializer valueDeserializer() {
+    final Schema commitOffsetSchemaV0 =
+        new Schema(
+            new Field("offset", Type.INT64, ""),
+            new Field("metadata", Type.STRING, ""),
+            new Field("commit_timestamp", Type.INT64, "")
+        );
+
+    final Schema commitOffsetSchemaV1 =
+        new Schema(
+            new Field("offset", Type.INT64, ""),
+            new Field("metadata", Type.STRING, ""),
+            new Field("commit_timestamp", Type.INT64, ""),
+            new Field("expire_timestamp", Type.INT64, "")
+        );
+
+    final Schema commitOffsetSchemaV2 =
+        new Schema(
+            new Field("offset", Type.INT64, ""),
+            new Field("metadata", Type.STRING, ""),
+            new Field("commit_timestamp", Type.INT64, "")
+        );
+
+    final Schema commitOffsetSchemaV3 =
+        new Schema(
+            new Field("offset", Type.INT64, ""),
+            new Field("leader_epoch", Type.INT32, ""),
+            new Field("metadata", Type.STRING, ""),
+            new Field("commit_timestamp", Type.INT64, "")
+        );
+
+    final Schema commitOffsetSchemaV4 = new Schema(
+        new Field("offset", Type.INT64, ""),
+        new Field("leader_epoch", Type.INT32, ""),
+        new Field("metadata", Type.COMPACT_STRING, ""),
+        new Field("commit_timestamp", Type.INT64, ""),
+        Field.TaggedFieldsSection.of()
+    );
+
+    final Schema metadataSchema0 =
+        new Schema(
+            new Field("protocol_type", Type.STRING, ""),
+            new Field("generation", Type.INT32, ""),
+            new Field("protocol", Type.NULLABLE_STRING, ""),
+            new Field("leader", Type.NULLABLE_STRING, ""),
+            new Field("members", new ArrayOf(new Schema(
+                new Field("member_id", Type.STRING, ""),
+                new Field("client_id", Type.STRING, ""),
+                new Field("client_host", Type.STRING, ""),
+                new Field("session_timeout", Type.INT32, ""),
+                new Field("subscription", Type.BYTES, ""),
+                new Field("assignment", Type.BYTES, "")
+            )), "")
+        );
+
+    final Schema metadataSchema1 =
+        new Schema(
+            new Field("protocol_type", Type.STRING, ""),
+            new Field("generation", Type.INT32, ""),
+            new Field("protocol", Type.NULLABLE_STRING, ""),
+            new Field("leader", Type.NULLABLE_STRING, ""),
+            new Field("members", new ArrayOf(new Schema(
+                new Field("member_id", Type.STRING, ""),
+                new Field("client_id", Type.STRING, ""),
+                new Field("client_host", Type.STRING, ""),
+                new Field("rebalance_timeout", Type.INT32, ""),
+                new Field("session_timeout", Type.INT32, ""),
+                new Field("subscription", Type.BYTES, ""),
+                new Field("assignment", Type.BYTES, "")
+            )), "")
+        );
+
+    final Schema metadataSchema2 =
+        new Schema(
+            new Field("protocol_type", Type.STRING, ""),
+            new Field("generation", Type.INT32, ""),
+            new Field("protocol", Type.NULLABLE_STRING, ""),
+            new Field("leader", Type.NULLABLE_STRING, ""),
+            new Field("current_state_timestamp", Type.INT64, ""),
+            new Field("members", new ArrayOf(new Schema(
+                new Field("member_id", Type.STRING, ""),
+                new Field("client_id", Type.STRING, ""),
+                new Field("client_host", Type.STRING, ""),
+                new Field("rebalance_timeout", Type.INT32, ""),
+                new Field("session_timeout", Type.INT32, ""),
+                new Field("subscription", Type.BYTES, ""),
+                new Field("assignment", Type.BYTES, "")
+            )), "")
+        );
+
+    final Schema metadataSchema3 =
+        new Schema(
+            new Field("protocol_type", Type.STRING, ""),
+            new Field("generation", Type.INT32, ""),
+            new Field("protocol", Type.NULLABLE_STRING, ""),
+            new Field("leader", Type.NULLABLE_STRING, ""),
+            new Field("current_state_timestamp", Type.INT64, ""),
+            new Field("members", new ArrayOf(new Schema(
+                new Field("member_id", Type.STRING, ""),
+                new Field("group_instance_id", Type.NULLABLE_STRING, ""),
+                new Field("client_id", Type.STRING, ""),
+                new Field("client_host", Type.STRING, ""),
+                new Field("rebalance_timeout", Type.INT32, ""),
+                new Field("session_timeout", Type.INT32, ""),
+                new Field("subscription", Type.BYTES, ""),
+                new Field("assignment", Type.BYTES, "")
+            )), "")
+        );
+
+    final Schema metadataSchema4 =
+        new Schema(
+            new Field("protocol_type", Type.COMPACT_STRING, ""),
+            new Field("generation", Type.INT32, ""),
+            new Field("protocol", Type.COMPACT_NULLABLE_STRING, ""),
+            new Field("leader", Type.COMPACT_NULLABLE_STRING, ""),
+            new Field("current_state_timestamp", Type.INT64, ""),
+            new Field("members", new CompactArrayOf(new Schema(
+                new Field("member_id", Type.COMPACT_STRING, ""),
+                new Field("group_instance_id", Type.COMPACT_NULLABLE_STRING, ""),
+                new Field("client_id", Type.COMPACT_STRING, ""),
+                new Field("client_host", Type.COMPACT_STRING, ""),
+                new Field("rebalance_timeout", Type.INT32, ""),
+                new Field("session_timeout", Type.INT32, ""),
+                new Field("subscription", Type.COMPACT_BYTES, ""),
+                new Field("assignment", Type.COMPACT_BYTES, ""),
+                Field.TaggedFieldsSection.of()
+            )), ""),
+            Field.TaggedFieldsSection.of()
+        );
+
+    return (headers, data) -> {
+      String result;
+      var bb = ByteBuffer.wrap(data);
+      short version = bb.getShort();
+      // ideally, we should distinguish if value is commit or metadata
+      // by checking record's key, but our current serde structure doesn't allow that.
+      // so, we trying to parse into metadata first and after into commit msg
+      try {
+        result = toJson(
+            switch (version) {
+              case 0 -> metadataSchema0.read(bb);
+              case 1 -> metadataSchema1.read(bb);
+              case 2 -> metadataSchema2.read(bb);
+              case 3 -> metadataSchema3.read(bb);
+              case 4 -> metadataSchema4.read(bb);
+              default -> throw new IllegalArgumentException("Unrecognized version: " + version);
+            }
+        );
+      } catch (Throwable e) {
+        bb = bb.rewind();
+        bb.getShort(); // skipping version
+        result = toJson(
+            switch (version) {
+              case 0 -> commitOffsetSchemaV0.read(bb);
+              case 1 -> commitOffsetSchemaV1.read(bb);
+              case 2 -> commitOffsetSchemaV2.read(bb);
+              case 3 -> commitOffsetSchemaV3.read(bb);
+              case 4 -> commitOffsetSchemaV4.read(bb);
+              default -> throw new IllegalArgumentException("Unrecognized version: " + version);
+            }
+        );
+      }
+
+      if (bb.remaining() != 0) {
+        throw new IllegalArgumentException(
+            "Message buffer is not read to the end, which is likely means message is unrecognized");
+      }
+      return new DeserializeResult(
+          result,
+          DeserializeResult.Type.JSON,
+          Map.of()
+      );
+    };
+  }
+
+  @SneakyThrows
+  private String toJson(Struct s) {
+    return JSON_MAPPER.writeValueAsString(s);
+  }
+}

+ 6 - 14
kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/builtin/ProtobufFileSerde.java

@@ -50,7 +50,6 @@ import io.confluent.kafka.schemaregistry.protobuf.ProtobufSchemaUtils;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayInputStream;
 import java.nio.file.Files;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Collection;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.List;
@@ -204,17 +203,13 @@ public class ProtobufFileSerde implements BuiltInSerde {
                        Map<String, Descriptor> keyMessageDescriptorMap) {
                        Map<String, Descriptor> keyMessageDescriptorMap) {
 
 
     static boolean canBeAutoConfigured(PropertyResolver kafkaClusterProperties) {
     static boolean canBeAutoConfigured(PropertyResolver kafkaClusterProperties) {
-      Optional<String> protobufFile = kafkaClusterProperties.getProperty("protobufFile", String.class);
       Optional<List<String>> protobufFiles = kafkaClusterProperties.getListProperty("protobufFiles", String.class);
       Optional<List<String>> protobufFiles = kafkaClusterProperties.getListProperty("protobufFiles", String.class);
       Optional<String> protobufFilesDir = kafkaClusterProperties.getProperty("protobufFilesDir", String.class);
       Optional<String> protobufFilesDir = kafkaClusterProperties.getProperty("protobufFilesDir", String.class);
-      return protobufFilesDir.isPresent()
-          || protobufFile.isPresent()
-          || protobufFiles.filter(files -> !files.isEmpty()).isPresent();
+      return protobufFilesDir.isPresent() || protobufFiles.filter(files -> !files.isEmpty()).isPresent();
     }
     }
 
 
     static Configuration create(PropertyResolver properties) {
     static Configuration create(PropertyResolver properties) {
       var protobufSchemas = loadSchemas(
       var protobufSchemas = loadSchemas(
-          properties.getProperty("protobufFile", String.class),
           properties.getListProperty("protobufFiles", String.class),
           properties.getListProperty("protobufFiles", String.class),
           properties.getProperty("protobufFilesDir", String.class)
           properties.getProperty("protobufFilesDir", String.class)
       );
       );
@@ -272,12 +267,11 @@ public class ProtobufFileSerde implements BuiltInSerde {
     }
     }
 
 
     @VisibleForTesting
     @VisibleForTesting
-    static Map<Path, ProtobufSchema> loadSchemas(Optional<String> protobufFile,
-                                                 Optional<List<String>> protobufFiles,
+    static Map<Path, ProtobufSchema> loadSchemas(Optional<List<String>> protobufFiles,
                                                  Optional<String> protobufFilesDir) {
                                                  Optional<String> protobufFilesDir) {
       if (protobufFilesDir.isPresent()) {
       if (protobufFilesDir.isPresent()) {
-        if (protobufFile.isPresent() || protobufFiles.isPresent()) {
-          log.warn("protobufFile and protobufFiles properties will be ignored, since protobufFilesDir provided");
+        if (protobufFiles.isPresent()) {
+          log.warn("protobufFiles properties will be ignored, since protobufFilesDir provided");
         }
         }
         List<ProtoFile> loadedFiles = new ProtoSchemaLoader(protobufFilesDir.get()).load();
         List<ProtoFile> loadedFiles = new ProtoSchemaLoader(protobufFilesDir.get()).load();
         Map<String, ProtoFileElement> allPaths = loadedFiles.stream()
         Map<String, ProtoFileElement> allPaths = loadedFiles.stream()
@@ -288,10 +282,8 @@ public class ProtobufFileSerde implements BuiltInSerde {
                 f -> new ProtobufSchema(f.toElement(), List.of(), allPaths)));
                 f -> new ProtobufSchema(f.toElement(), List.of(), allPaths)));
       }
       }
       //Supporting for backward-compatibility. Normally, protobufFilesDir setting should be used
       //Supporting for backward-compatibility. Normally, protobufFilesDir setting should be used
-      return Stream.concat(
-              protobufFile.stream(),
-              protobufFiles.stream().flatMap(Collection::stream)
-          )
+      return protobufFiles.stream()
+          .flatMap(Collection::stream)
           .distinct()
           .distinct()
           .map(Path::of)
           .map(Path::of)
           .collect(Collectors.toMap(path -> path, path -> new ProtobufSchema(readFileAsString(path))));
           .collect(Collectors.toMap(path -> path, path -> new ProtobufSchema(readFileAsString(path))));

+ 2 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java

@@ -18,6 +18,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.kafka.clients.admin.ConfigEntry;
 import org.apache.kafka.clients.admin.ConfigEntry;
@@ -118,7 +119,7 @@ public class BrokerService {
               .stream()
               .stream()
               .map(Node::id)
               .map(Node::id)
               .collect(Collectors.toList());
               .collect(Collectors.toList());
-          if (reqBrokers != null && !reqBrokers.isEmpty()) {
+          if (!reqBrokers.isEmpty()) {
             brokers.retainAll(reqBrokers);
             brokers.retainAll(reqBrokers);
           }
           }
           return admin.describeLogDirs(brokers);
           return admin.describeLogDirs(brokers);

+ 52 - 7
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/MessagesService.java

@@ -14,11 +14,16 @@ import com.provectus.kafka.ui.model.CreateTopicMessageDTO;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.MessageFilterTypeDTO;
 import com.provectus.kafka.ui.model.MessageFilterTypeDTO;
 import com.provectus.kafka.ui.model.SeekDirectionDTO;
 import com.provectus.kafka.ui.model.SeekDirectionDTO;
+import com.provectus.kafka.ui.model.SmartFilterTestExecutionDTO;
+import com.provectus.kafka.ui.model.SmartFilterTestExecutionResultDTO;
 import com.provectus.kafka.ui.model.TopicMessageDTO;
 import com.provectus.kafka.ui.model.TopicMessageDTO;
 import com.provectus.kafka.ui.model.TopicMessageEventDTO;
 import com.provectus.kafka.ui.model.TopicMessageEventDTO;
 import com.provectus.kafka.ui.serde.api.Serde;
 import com.provectus.kafka.ui.serde.api.Serde;
 import com.provectus.kafka.ui.serdes.ProducerRecordCreator;
 import com.provectus.kafka.ui.serdes.ProducerRecordCreator;
 import com.provectus.kafka.ui.util.SslPropertiesUtil;
 import com.provectus.kafka.ui.util.SslPropertiesUtil;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Optional;
@@ -81,6 +86,40 @@ public class MessagesService {
         .switchIfEmpty(Mono.error(new TopicNotFoundException()));
         .switchIfEmpty(Mono.error(new TopicNotFoundException()));
   }
   }
 
 
+  public static SmartFilterTestExecutionResultDTO execSmartFilterTest(SmartFilterTestExecutionDTO execData) {
+    Predicate<TopicMessageDTO> predicate;
+    try {
+      predicate = MessageFilters.createMsgFilter(
+          execData.getFilterCode(),
+          MessageFilterTypeDTO.GROOVY_SCRIPT
+      );
+    } catch (Exception e) {
+      log.info("Smart filter '{}' compilation error", execData.getFilterCode(), e);
+      return new SmartFilterTestExecutionResultDTO()
+          .error("Compilation error : " + e.getMessage());
+    }
+    try {
+      var result = predicate.test(
+          new TopicMessageDTO()
+              .key(execData.getKey())
+              .content(execData.getValue())
+              .headers(execData.getHeaders())
+              .offset(execData.getOffset())
+              .partition(execData.getPartition())
+              .timestamp(
+                  Optional.ofNullable(execData.getTimestampMs())
+                      .map(ts -> OffsetDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneOffset.UTC))
+                      .orElse(null))
+      );
+      return new SmartFilterTestExecutionResultDTO()
+          .result(result);
+    } catch (Exception e) {
+      log.info("Smart filter {} execution error", execData, e);
+      return new SmartFilterTestExecutionResultDTO()
+          .error("Execution error : " + e.getMessage());
+    }
+  }
+
   public Mono<Void> deleteTopicMessages(KafkaCluster cluster, String topicName,
   public Mono<Void> deleteTopicMessages(KafkaCluster cluster, String topicName,
                                         List<Integer> partitionsToInclude) {
                                         List<Integer> partitionsToInclude) {
     return withExistingTopic(cluster, topicName)
     return withExistingTopic(cluster, topicName)
@@ -127,13 +166,7 @@ public class MessagesService {
             msg.getValueSerde().get()
             msg.getValueSerde().get()
         );
         );
 
 
-    Properties properties = new Properties();
-    SslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), properties);
-    properties.putAll(cluster.getProperties());
-    properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, cluster.getBootstrapServers());
-    properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
-    properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
-    try (KafkaProducer<byte[], byte[]> producer = new KafkaProducer<>(properties)) {
+    try (KafkaProducer<byte[], byte[]> producer = createProducer(cluster, Map.of())) {
       ProducerRecord<byte[], byte[]> producerRecord = producerRecordCreator.create(
       ProducerRecord<byte[], byte[]> producerRecord = producerRecordCreator.create(
           topicDescription.name(),
           topicDescription.name(),
           msg.getPartition(),
           msg.getPartition(),
@@ -155,6 +188,18 @@ public class MessagesService {
     }
     }
   }
   }
 
 
+  public static KafkaProducer<byte[], byte[]> createProducer(KafkaCluster cluster,
+                                                             Map<String, Object> additionalProps) {
+    Properties properties = new Properties();
+    SslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), properties);
+    properties.putAll(cluster.getProperties());
+    properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, cluster.getBootstrapServers());
+    properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
+    properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
+    properties.putAll(additionalProps);
+    return new KafkaProducer<>(properties);
+  }
+
   public Flux<TopicMessageEventDTO> loadMessages(KafkaCluster cluster, String topic,
   public Flux<TopicMessageEventDTO> loadMessages(KafkaCluster cluster, String topic,
                                                  ConsumerPosition consumerPosition,
                                                  ConsumerPosition consumerPosition,
                                                  @Nullable String query,
                                                  @Nullable String query,

+ 9 - 12
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/TopicsService.java

@@ -170,21 +170,18 @@ public class TopicsService {
             .map(m -> m.values().stream().findFirst().orElse(List.of())));
             .map(m -> m.values().stream().findFirst().orElse(List.of())));
   }
   }
 
 
-  private Mono<InternalTopic> createTopic(KafkaCluster c, ReactiveAdminClient adminClient,
-                                          Mono<TopicCreationDTO> topicCreation) {
-    return topicCreation.flatMap(topicData ->
-            adminClient.createTopic(
-                topicData.getName(),
-                topicData.getPartitions(),
-                topicData.getReplicationFactor(),
-                topicData.getConfigs()
-            ).thenReturn(topicData)
-        )
+  private Mono<InternalTopic> createTopic(KafkaCluster c, ReactiveAdminClient adminClient, TopicCreationDTO topicData) {
+    return adminClient.createTopic(
+            topicData.getName(),
+            topicData.getPartitions(),
+            topicData.getReplicationFactor(),
+            topicData.getConfigs())
+        .thenReturn(topicData)
         .onErrorMap(t -> new TopicMetadataException(t.getMessage(), t))
         .onErrorMap(t -> new TopicMetadataException(t.getMessage(), t))
-        .flatMap(topicData -> loadTopicAfterCreation(c, topicData.getName()));
+        .then(loadTopicAfterCreation(c, topicData.getName()));
   }
   }
 
 
-  public Mono<InternalTopic> createTopic(KafkaCluster cluster, Mono<TopicCreationDTO> topicCreation) {
+  public Mono<InternalTopic> createTopic(KafkaCluster cluster, TopicCreationDTO topicCreation) {
     return adminClientService.get(cluster)
     return adminClientService.get(cluster)
         .flatMap(ac -> createTopic(cluster, ac, topicCreation));
         .flatMap(ac -> createTopic(cluster, ac, topicCreation));
   }
   }

+ 97 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/audit/AuditRecord.java

@@ -0,0 +1,97 @@
+package com.provectus.kafka.ui.service.audit;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.provectus.kafka.ui.exception.CustomBaseException;
+import com.provectus.kafka.ui.exception.ValidationException;
+import com.provectus.kafka.ui.model.rbac.AccessContext;
+import com.provectus.kafka.ui.model.rbac.Resource;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.SneakyThrows;
+import org.springframework.security.access.AccessDeniedException;
+
+record AuditRecord(String timestamp,
+                   String username,
+                   String clusterName,
+                   List<AuditResource> resources,
+                   String operation,
+                   Object operationParams,
+                   OperationResult result) {
+
+  static final JsonMapper MAPPER = new JsonMapper();
+
+  static {
+    MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+  }
+
+  @SneakyThrows
+  String toJson() {
+    return MAPPER.writeValueAsString(this);
+  }
+
+  record AuditResource(String accessType, Resource type, @Nullable Object id) {
+
+    static List<AuditResource> getAccessedResources(AccessContext ctx) {
+      List<AuditResource> resources = new ArrayList<>();
+      ctx.getClusterConfigActions()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.CLUSTERCONFIG, null)));
+      ctx.getTopicActions()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.TOPIC, nameId(ctx.getTopic()))));
+      ctx.getConsumerGroupActions()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.CONSUMER, nameId(ctx.getConsumerGroup()))));
+      ctx.getConnectActions()
+          .forEach(a -> {
+            Map<String, String> resourceId = new LinkedHashMap<>();
+            resourceId.put("connect", ctx.getConnect());
+            if (ctx.getConnector() != null) {
+              resourceId.put("connector", ctx.getConnector());
+            }
+            resources.add(new AuditResource(a.name(), Resource.CONNECT, resourceId));
+          });
+      ctx.getSchemaActions()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.SCHEMA, nameId(ctx.getSchema()))));
+      ctx.getKsqlActions()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.KSQL, null)));
+      ctx.getAclActions()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.ACL, null)));
+      ctx.getAuditAction()
+          .forEach(a -> resources.add(new AuditResource(a.name(), Resource.AUDIT, null)));
+      return resources;
+    }
+
+    @Nullable
+    private static Map<String, Object> nameId(@Nullable String name) {
+      return name != null ? Map.of("name", name) : null;
+    }
+  }
+
+  record OperationResult(boolean success, OperationError error) {
+
+    static OperationResult successful() {
+      return new OperationResult(true, null);
+    }
+
+    static OperationResult error(Throwable th) {
+      OperationError err = OperationError.UNRECOGNIZED_ERROR;
+      if (th instanceof AccessDeniedException) {
+        err = OperationError.ACCESS_DENIED;
+      } else if (th instanceof ValidationException) {
+        err = OperationError.VALIDATION_ERROR;
+      } else if (th instanceof CustomBaseException) {
+        err = OperationError.EXECUTION_ERROR;
+      }
+      return new OperationResult(false, err);
+    }
+
+    enum OperationError {
+      ACCESS_DENIED,
+      VALIDATION_ERROR,
+      EXECUTION_ERROR,
+      UNRECOGNIZED_ERROR
+    }
+  }
+}

+ 209 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/audit/AuditService.java

@@ -0,0 +1,209 @@
+package com.provectus.kafka.ui.service.audit;
+
+import static com.provectus.kafka.ui.service.MessagesService.createProducer;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.provectus.kafka.ui.config.ClustersProperties;
+import com.provectus.kafka.ui.config.auth.AuthenticatedUser;
+import com.provectus.kafka.ui.config.auth.RbacUser;
+import com.provectus.kafka.ui.model.KafkaCluster;
+import com.provectus.kafka.ui.model.rbac.AccessContext;
+import com.provectus.kafka.ui.service.AdminClientService;
+import com.provectus.kafka.ui.service.ClustersStorage;
+import com.provectus.kafka.ui.service.ReactiveAdminClient;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Signal;
+
+
+@Slf4j
+@Service
+public class AuditService implements Closeable {
+
+  private static final Mono<AuthenticatedUser> NO_AUTH_USER = Mono.just(new AuthenticatedUser("Unknown", Set.of()));
+
+  private static final String DEFAULT_AUDIT_TOPIC_NAME = "__kui-audit-log";
+  private static final int DEFAULT_AUDIT_TOPIC_PARTITIONS = 1;
+  private static final Map<String, String> DEFAULT_AUDIT_TOPIC_CONFIG = Map.of(
+      "retention.ms", String.valueOf(TimeUnit.DAYS.toMillis(7)),
+      "cleanup.policy", "delete"
+  );
+  private static final Map<String, Object> AUDIT_PRODUCER_CONFIG = Map.of(
+      ProducerConfig.COMPRESSION_TYPE_CONFIG, "gzip"
+  );
+
+  private static final Logger AUDIT_LOGGER = LoggerFactory.getLogger("audit");
+
+  private final Map<String, AuditWriter> auditWriters;
+
+  @Autowired
+  public AuditService(AdminClientService adminClientService, ClustersStorage clustersStorage) {
+    Map<String, AuditWriter> auditWriters = new HashMap<>();
+    for (var cluster : clustersStorage.getKafkaClusters()) {
+      ReactiveAdminClient adminClient;
+      try {
+        adminClient = adminClientService.get(cluster).block();
+      } catch (Exception e) {
+        printAuditInitError(cluster, "Error connect to cluster", e);
+        continue;
+      }
+      createAuditWriter(cluster, adminClient, () -> createProducer(cluster, AUDIT_PRODUCER_CONFIG))
+          .ifPresent(writer -> auditWriters.put(cluster.getName(), writer));
+    }
+    this.auditWriters = auditWriters;
+  }
+
+  @VisibleForTesting
+  AuditService(Map<String, AuditWriter> auditWriters) {
+    this.auditWriters = auditWriters;
+  }
+
+  @VisibleForTesting
+  static Optional<AuditWriter> createAuditWriter(KafkaCluster cluster,
+                                                 ReactiveAdminClient ac,
+                                                 Supplier<KafkaProducer<byte[], byte[]>> producerFactory) {
+    var auditProps = cluster.getOriginalProperties().getAudit();
+    if (auditProps == null) {
+      return Optional.empty();
+    }
+    boolean topicAudit = Optional.ofNullable(auditProps.getTopicAuditEnabled()).orElse(false);
+    boolean consoleAudit = Optional.ofNullable(auditProps.getConsoleAuditEnabled()).orElse(false);
+    if (!topicAudit && !consoleAudit) {
+      return Optional.empty();
+    }
+    String auditTopicName = Optional.ofNullable(auditProps.getTopic()).orElse(DEFAULT_AUDIT_TOPIC_NAME);
+    @Nullable KafkaProducer<byte[], byte[]> producer = null;
+    if (topicAudit && createTopicIfNeeded(cluster, ac, auditTopicName, auditProps)) {
+      producer = producerFactory.get();
+    }
+    log.info("Audit service initialized for cluster '{}'", cluster.getName());
+    return Optional.of(
+        new AuditWriter(
+            cluster.getName(),
+            auditTopicName,
+            producer,
+            consoleAudit ? AUDIT_LOGGER : null
+        )
+    );
+  }
+
+  /**
+   * return true if topic created/existing and producing can be enabled.
+   */
+  private static boolean createTopicIfNeeded(KafkaCluster cluster,
+                                             ReactiveAdminClient ac,
+                                             String auditTopicName,
+                                             ClustersProperties.AuditProperties auditProps) {
+    boolean topicExists;
+    try {
+      topicExists = ac.listTopics(true).block().contains(auditTopicName);
+    } catch (Exception e) {
+      printAuditInitError(cluster, "Error checking audit topic existence", e);
+      return false;
+    }
+    if (topicExists) {
+      return true;
+    }
+    try {
+      int topicPartitions =
+          Optional.ofNullable(auditProps.getAuditTopicsPartitions())
+              .orElse(DEFAULT_AUDIT_TOPIC_PARTITIONS);
+
+      Map<String, String> topicConfig = new HashMap<>(DEFAULT_AUDIT_TOPIC_CONFIG);
+      Optional.ofNullable(auditProps.getAuditTopicProperties())
+          .ifPresent(topicConfig::putAll);
+
+      log.info("Creating audit topic '{}' for cluster '{}'", auditTopicName, cluster.getName());
+      ac.createTopic(auditTopicName, topicPartitions, null, topicConfig).block();
+      log.info("Audit topic created for cluster '{}'", cluster.getName());
+      return true;
+    } catch (Exception e) {
+      printAuditInitError(cluster, "Error creating topic '%s'".formatted(auditTopicName), e);
+      return false;
+    }
+  }
+
+  private static void printAuditInitError(KafkaCluster cluster, String errorMsg, Exception cause) {
+    log.error("-----------------------------------------------------------------");
+    log.error(
+        "Error initializing Audit Service for cluster '{}'. Audit will be disabled. See error below: ",
+        cluster.getName()
+    );
+    log.error("{}", errorMsg, cause);
+    log.error("-----------------------------------------------------------------");
+  }
+
+  public boolean isAuditTopic(KafkaCluster cluster, String topic) {
+    var writer = auditWriters.get(cluster.getName());
+    return writer != null
+        && topic.equals(writer.targetTopic())
+        && writer.isTopicWritingEnabled();
+  }
+
+  public void audit(AccessContext acxt, Signal<?> sig) {
+    if (sig.isOnComplete()) {
+      extractUser(sig)
+          .doOnNext(u -> sendAuditRecord(acxt, u))
+          .subscribe();
+    } else if (sig.isOnError()) {
+      extractUser(sig)
+          .doOnNext(u -> sendAuditRecord(acxt, u, sig.getThrowable()))
+          .subscribe();
+    }
+  }
+
+  private Mono<AuthenticatedUser> extractUser(Signal<?> sig) {
+    //see ReactiveSecurityContextHolder for impl details
+    Object key = SecurityContext.class;
+    if (sig.getContextView().hasKey(key)) {
+      return sig.getContextView().<Mono<SecurityContext>>get(key)
+          .map(context -> context.getAuthentication().getPrincipal())
+          .cast(RbacUser.class)
+          .map(user -> new AuthenticatedUser(user.name(), user.groups()))
+          .switchIfEmpty(NO_AUTH_USER);
+    } else {
+      return NO_AUTH_USER;
+    }
+  }
+
+  private void sendAuditRecord(AccessContext ctx, AuthenticatedUser user) {
+    sendAuditRecord(ctx, user, null);
+  }
+
+  private void sendAuditRecord(AccessContext ctx, AuthenticatedUser user, @Nullable Throwable th) {
+    try {
+      if (ctx.getCluster() != null) {
+        var writer = auditWriters.get(ctx.getCluster());
+        if (writer != null) {
+          writer.write(ctx, user, th);
+        }
+      } else {
+        // cluster-independent operation
+        AuditWriter.writeAppOperation(AUDIT_LOGGER, ctx, user, th);
+      }
+    } catch (Exception e) {
+      log.warn("Error sending audit record", e);
+    }
+  }
+
+  @Override
+  public void close() throws IOException {
+    auditWriters.values().forEach(AuditWriter::close);
+  }
+}

+ 78 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/audit/AuditWriter.java

@@ -0,0 +1,78 @@
+package com.provectus.kafka.ui.service.audit;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.provectus.kafka.ui.config.auth.AuthenticatedUser;
+import com.provectus.kafka.ui.model.rbac.AccessContext;
+import com.provectus.kafka.ui.service.audit.AuditRecord.AuditResource;
+import com.provectus.kafka.ui.service.audit.AuditRecord.OperationResult;
+import java.io.Closeable;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.slf4j.Logger;
+
+@Slf4j
+record AuditWriter(String clusterName,
+                   String targetTopic,
+                   @Nullable KafkaProducer<byte[], byte[]> producer,
+                   @Nullable Logger consoleLogger) implements Closeable {
+
+  boolean isTopicWritingEnabled() {
+    return producer != null;
+  }
+
+  // application-level (cluster-independent) operation
+  static void writeAppOperation(Logger consoleLogger,
+                                AccessContext ctx,
+                                AuthenticatedUser user,
+                                @Nullable Throwable th) {
+    consoleLogger.info(createRecord(ctx, user, th).toJson());
+  }
+
+  void write(AccessContext ctx, AuthenticatedUser user, @Nullable Throwable th) {
+    write(createRecord(ctx, user, th));
+  }
+
+  private void write(AuditRecord rec) {
+    String json = rec.toJson();
+    if (consoleLogger != null) {
+      consoleLogger.info(json);
+    }
+    if (producer != null) {
+      producer.send(
+          new ProducerRecord<>(targetTopic, null, json.getBytes(UTF_8)),
+          (metadata, ex) -> {
+            if (ex != null) {
+              log.warn("Error sending Audit record to kafka for cluster {}", clusterName, ex);
+            }
+          });
+    }
+  }
+
+  private static AuditRecord createRecord(AccessContext ctx,
+                                          AuthenticatedUser user,
+                                          @Nullable Throwable th) {
+    return new AuditRecord(
+        DateTimeFormatter.ISO_INSTANT.format(Instant.now()),
+        user.principal(),
+        ctx.getCluster(), //can be null, if it is application-level action
+        AuditResource.getAccessedResources(ctx),
+        ctx.getOperationName(),
+        ctx.getOperationParams(),
+        th == null ? OperationResult.successful() : OperationResult.error(th)
+    );
+  }
+
+  @Override
+  public void close() {
+    Optional.ofNullable(producer).ifPresent(KafkaProducer::close);
+  }
+
+}
+
+

+ 3 - 6
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/integration/odd/OddExporter.java

@@ -5,13 +5,10 @@ import com.google.common.base.Preconditions;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.service.KafkaConnectService;
 import com.provectus.kafka.ui.service.KafkaConnectService;
 import com.provectus.kafka.ui.service.StatisticsCache;
 import com.provectus.kafka.ui.service.StatisticsCache;
-import java.util.List;
 import java.util.function.Predicate;
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
 import java.util.regex.Pattern;
-import lombok.SneakyThrows;
 import org.opendatadiscovery.client.ApiClient;
 import org.opendatadiscovery.client.ApiClient;
 import org.opendatadiscovery.client.api.OpenDataDiscoveryIngestionApi;
 import org.opendatadiscovery.client.api.OpenDataDiscoveryIngestionApi;
-import org.opendatadiscovery.client.model.DataEntity;
 import org.opendatadiscovery.client.model.DataEntityList;
 import org.opendatadiscovery.client.model.DataEntityList;
 import org.opendatadiscovery.client.model.DataSource;
 import org.opendatadiscovery.client.model.DataSource;
 import org.opendatadiscovery.client.model.DataSourceList;
 import org.opendatadiscovery.client.model.DataSourceList;
@@ -68,14 +65,14 @@ class OddExporter {
   private Mono<Void> exportTopics(KafkaCluster c) {
   private Mono<Void> exportTopics(KafkaCluster c) {
     return createKafkaDataSource(c)
     return createKafkaDataSource(c)
         .thenMany(topicsExporter.export(c))
         .thenMany(topicsExporter.export(c))
-        .concatMap(this::sentDataEntities)
+        .concatMap(this::sendDataEntities)
         .then();
         .then();
   }
   }
 
 
   private Mono<Void> exportKafkaConnects(KafkaCluster cluster) {
   private Mono<Void> exportKafkaConnects(KafkaCluster cluster) {
     return createConnectDataSources(cluster)
     return createConnectDataSources(cluster)
         .thenMany(connectorsExporter.export(cluster))
         .thenMany(connectorsExporter.export(cluster))
-        .concatMap(this::sentDataEntities)
+        .concatMap(this::sendDataEntities)
         .then();
         .then();
   }
   }
 
 
@@ -99,7 +96,7 @@ class OddExporter {
     );
     );
   }
   }
 
 
-  private Mono<Void> sentDataEntities(DataEntityList dataEntityList) {
+  private Mono<Void> sendDataEntities(DataEntityList dataEntityList) {
     return oddApi.postDataEntityList(dataEntityList);
     return oddApi.postDataEntityList(dataEntityList);
   }
   }
 
 

+ 5 - 5
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/integration/odd/TopicsExporter.java

@@ -39,6 +39,8 @@ class TopicsExporter {
     return Flux.fromIterable(clusterState.getTopicStates().keySet())
     return Flux.fromIterable(clusterState.getTopicStates().keySet())
         .filter(topicFilter)
         .filter(topicFilter)
         .flatMap(topic -> createTopicDataEntity(cluster, topic, clusterState.getTopicStates().get(topic)))
         .flatMap(topic -> createTopicDataEntity(cluster, topic, clusterState.getTopicStates().get(topic)))
+        .onErrorContinue(
+            (th, topic) -> log.warn("Error exporting data for topic {}, cluster {}", topic, cluster.getName(), th))
         .buffer(100)
         .buffer(100)
         .map(topicsEntities ->
         .map(topicsEntities ->
             new DataEntityList()
             new DataEntityList()
@@ -90,10 +92,10 @@ class TopicsExporter {
         .build();
         .build();
   }
   }
 
 
+  //returns empty list if schemaRegistry is not configured or assumed subject not found
   private Mono<List<DataSetField>> getTopicSchema(KafkaCluster cluster,
   private Mono<List<DataSetField>> getTopicSchema(KafkaCluster cluster,
                                                   String topic,
                                                   String topic,
                                                   KafkaPath topicOddrn,
                                                   KafkaPath topicOddrn,
-                                                  //currently we only retrieve value schema
                                                   boolean isKey) {
                                                   boolean isKey) {
     if (cluster.getSchemaRegistryClient() == null) {
     if (cluster.getSchemaRegistryClient() == null) {
       return Mono.just(List.of());
       return Mono.just(List.of());
@@ -103,10 +105,8 @@ class TopicsExporter {
         .mono(client -> client.getSubjectVersion(subject, "latest"))
         .mono(client -> client.getSubjectVersion(subject, "latest"))
         .map(subj -> DataSetFieldsExtractors.extract(subj, topicOddrn, isKey))
         .map(subj -> DataSetFieldsExtractors.extract(subj, topicOddrn, isKey))
         .onErrorResume(WebClientResponseException.NotFound.class, th -> Mono.just(List.of()))
         .onErrorResume(WebClientResponseException.NotFound.class, th -> Mono.just(List.of()))
-        .onErrorResume(th -> true, th -> {
-          log.warn("Error retrieving subject {} for cluster {}", subject, cluster.getName(), th);
-          return Mono.just(List.of());
-        });
+        .onErrorMap(WebClientResponseException.class, err ->
+            new IllegalStateException("Error retrieving subject %s".formatted(subject), err));
   }
   }
 
 
 }
 }

+ 19 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java

@@ -109,7 +109,8 @@ public class AccessControlService {
                   && isConnectorAccessible(context, user) // TODO connector selectors
                   && isConnectorAccessible(context, user) // TODO connector selectors
                   && isSchemaAccessible(context, user)
                   && isSchemaAccessible(context, user)
                   && isKsqlAccessible(context, user)
                   && isKsqlAccessible(context, user)
-                  && isAclAccessible(context, user);
+                  && isAclAccessible(context, user)
+                  && isAuditAccessible(context, user);
 
 
           if (!accessGranted) {
           if (!accessGranted) {
             throw new AccessDeniedException("Access denied");
             throw new AccessDeniedException("Access denied");
@@ -386,6 +387,23 @@ public class AccessControlService {
     return isAccessible(Resource.ACL, null, user, context, requiredActions);
     return isAccessible(Resource.ACL, null, user, context, requiredActions);
   }
   }
 
 
+  private boolean isAuditAccessible(AccessContext context, AuthenticatedUser user) {
+    if (!rbacEnabled) {
+      return true;
+    }
+
+    if (context.getAuditAction().isEmpty()) {
+      return true;
+    }
+
+    Set<String> requiredActions = context.getAuditAction()
+        .stream()
+        .map(a -> a.toString().toUpperCase())
+        .collect(Collectors.toSet());
+
+    return isAccessible(Resource.AUDIT, null, user, context, requiredActions);
+  }
+
   public Set<ProviderAuthorityExtractor> getOauthExtractors() {
   public Set<ProviderAuthorityExtractor> getOauthExtractors() {
     return oauthExtractors;
     return oauthExtractors;
   }
   }

+ 78 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java

@@ -0,0 +1,78 @@
+package com.provectus.kafka.ui.service.rbac.extractor;
+
+import com.provectus.kafka.ui.config.auth.LdapProperties;
+import com.provectus.kafka.ui.model.rbac.Role;
+import com.provectus.kafka.ui.model.rbac.provider.Provider;
+import com.provectus.kafka.ui.service.rbac.AccessControlService;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationContext;
+import org.springframework.ldap.core.DirContextOperations;
+import org.springframework.ldap.core.support.BaseLdapPathContextSource;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
+import org.springframework.util.Assert;
+
+@Slf4j
+public class RbacLdapAuthoritiesExtractor extends DefaultLdapAuthoritiesPopulator {
+
+  private final AccessControlService acs;
+  private final LdapProperties props;
+
+  public RbacLdapAuthoritiesExtractor(ApplicationContext context,
+                                      BaseLdapPathContextSource contextSource, String groupFilterSearchBase) {
+    super(contextSource, groupFilterSearchBase);
+    this.acs = context.getBean(AccessControlService.class);
+    this.props = context.getBean(LdapProperties.class);
+  }
+
+  @Override
+  protected Set<GrantedAuthority> getAdditionalRoles(DirContextOperations user, String username) {
+    var ldapGroups = getRoles(user.getNameInNamespace(), username);
+
+    return acs.getRoles()
+        .stream()
+        .filter(r -> r.getSubjects()
+            .stream()
+            .filter(subject -> subject.getProvider().equals(Provider.LDAP))
+            .filter(subject -> subject.getType().equals("group"))
+            .anyMatch(subject -> ldapGroups.contains(subject.getValue()))
+        )
+        .map(Role::getName)
+        .peek(role -> log.trace("Mapped role [{}] for user [{}]", role, username))
+        .map(SimpleGrantedAuthority::new)
+        .collect(Collectors.toSet());
+  }
+
+  private Set<String> getRoles(String userDn, String username) {
+    var groupSearchBase = props.getGroupFilterSearchBase();
+    Assert.notNull(groupSearchBase, "groupSearchBase is empty");
+
+    var groupRoleAttribute = props.getGroupRoleAttribute();
+    if (groupRoleAttribute == null) {
+
+      groupRoleAttribute = "cn";
+    }
+
+    log.trace(
+        "Searching for roles for user [{}] with DN [{}], groupRoleAttribute [{}] and filter [{}] in search base [{}]",
+        username, userDn, groupRoleAttribute, getGroupSearchFilter(), groupSearchBase);
+
+    var ldapTemplate = getLdapTemplate();
+    ldapTemplate.setIgnoreNameNotFoundException(true);
+
+    Set<Map<String, List<String>>> userRoles = ldapTemplate.searchForMultipleAttributeValues(
+        groupSearchBase, getGroupSearchFilter(), new String[] {userDn, username},
+        new String[] {groupRoleAttribute});
+
+    return userRoles.stream()
+        .map(record -> record.get(getGroupRoleAttribute()).get(0))
+        .peek(group -> log.trace("Found LDAP group [{}] for user [{}]", group, username))
+        .collect(Collectors.toSet());
+  }
+
+}

+ 11 - 25
kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/jsonschema/AvroJsonSchemaConverter.java

@@ -151,30 +151,16 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
   }
   }
 
 
   private JsonType convertType(Schema schema) {
   private JsonType convertType(Schema schema) {
-    switch (schema.getType()) {
-      case INT:
-      case LONG:
-        return new SimpleJsonType(JsonType.Type.INTEGER);
-      case MAP:
-      case RECORD:
-        return new SimpleJsonType(JsonType.Type.OBJECT);
-      case ENUM:
-        return new EnumJsonType(schema.getEnumSymbols());
-      case BYTES:
-      case STRING:
-        return new SimpleJsonType(JsonType.Type.STRING);
-      case NULL:
-        return new SimpleJsonType(JsonType.Type.NULL);
-      case ARRAY:
-        return new SimpleJsonType(JsonType.Type.ARRAY);
-      case FIXED:
-      case FLOAT:
-      case DOUBLE:
-        return new SimpleJsonType(JsonType.Type.NUMBER);
-      case BOOLEAN:
-        return new SimpleJsonType(JsonType.Type.BOOLEAN);
-      default:
-        return new SimpleJsonType(JsonType.Type.STRING);
-    }
+    return switch (schema.getType()) {
+      case INT, LONG -> new SimpleJsonType(JsonType.Type.INTEGER);
+      case MAP, RECORD -> new SimpleJsonType(JsonType.Type.OBJECT);
+      case ENUM -> new EnumJsonType(schema.getEnumSymbols());
+      case BYTES, STRING -> new SimpleJsonType(JsonType.Type.STRING);
+      case NULL -> new SimpleJsonType(JsonType.Type.NULL);
+      case ARRAY -> new SimpleJsonType(JsonType.Type.ARRAY);
+      case FIXED, FLOAT, DOUBLE -> new SimpleJsonType(JsonType.Type.NUMBER);
+      case BOOLEAN -> new SimpleJsonType(JsonType.Type.BOOLEAN);
+      default -> new SimpleJsonType(JsonType.Type.STRING);
+    };
   }
   }
 }
 }

+ 61 - 22
kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/jsonschema/JsonAvroConversion.java

@@ -1,6 +1,7 @@
 package com.provectus.kafka.ui.util.jsonschema;
 package com.provectus.kafka.ui.util.jsonschema;
 
 
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.json.JsonMapper;
 import com.fasterxml.jackson.databind.json.JsonMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -15,7 +16,7 @@ import com.fasterxml.jackson.databind.node.NullNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.fasterxml.jackson.databind.node.TextNode;
 import com.fasterxml.jackson.databind.node.TextNode;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Lists;
-import com.provectus.kafka.ui.exception.JsonToAvroConversionException;
+import com.provectus.kafka.ui.exception.JsonAvroConversionException;
 import io.confluent.kafka.serializers.AvroData;
 import io.confluent.kafka.serializers.AvroData;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.nio.ByteBuffer;
 import java.nio.ByteBuffer;
@@ -34,7 +35,6 @@ import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeUnit;
 import java.util.function.BiFunction;
 import java.util.function.BiFunction;
 import java.util.stream.Stream;
 import java.util.stream.Stream;
-import lombok.SneakyThrows;
 import org.apache.avro.Schema;
 import org.apache.avro.Schema;
 import org.apache.avro.generic.GenericData;
 import org.apache.avro.generic.GenericData;
 
 
@@ -42,12 +42,17 @@ import org.apache.avro.generic.GenericData;
 public class JsonAvroConversion {
 public class JsonAvroConversion {
 
 
   private static final JsonMapper MAPPER = new JsonMapper();
   private static final JsonMapper MAPPER = new JsonMapper();
+  private static final Schema NULL_SCHEMA = Schema.create(Schema.Type.NULL);
 
 
   // converts json into Object that is expected input for KafkaAvroSerializer
   // converts json into Object that is expected input for KafkaAvroSerializer
   // (with AVRO_USE_LOGICAL_TYPE_CONVERTERS flat enabled!)
   // (with AVRO_USE_LOGICAL_TYPE_CONVERTERS flat enabled!)
-  @SneakyThrows
   public static Object convertJsonToAvro(String jsonString, Schema avroSchema) {
   public static Object convertJsonToAvro(String jsonString, Schema avroSchema) {
-    JsonNode rootNode = MAPPER.readTree(jsonString);
+    JsonNode rootNode = null;
+    try {
+      rootNode = MAPPER.readTree(jsonString);
+    } catch (JsonProcessingException e) {
+      throw new JsonAvroConversionException("String is not a valid json");
+    }
     return convert(rootNode, avroSchema);
     return convert(rootNode, avroSchema);
   }
   }
 
 
@@ -80,7 +85,7 @@ public class JsonAvroConversion {
         assertJsonType(node, JsonNodeType.STRING);
         assertJsonType(node, JsonNodeType.STRING);
         String symbol = node.textValue();
         String symbol = node.textValue();
         if (!avroSchema.getEnumSymbols().contains(symbol)) {
         if (!avroSchema.getEnumSymbols().contains(symbol)) {
-          throw new JsonToAvroConversionException("%s is not a part of enum symbols [%s]"
+          throw new JsonAvroConversionException("%s is not a part of enum symbols [%s]"
               .formatted(symbol, avroSchema.getEnumSymbols()));
               .formatted(symbol, avroSchema.getEnumSymbols()));
         }
         }
         yield new GenericData.EnumSymbol(avroSchema, symbol);
         yield new GenericData.EnumSymbol(avroSchema, symbol);
@@ -88,23 +93,35 @@ public class JsonAvroConversion {
       case UNION -> {
       case UNION -> {
         // for types from enum (other than null) payload should be an object with single key == name of type
         // for types from enum (other than null) payload should be an object with single key == name of type
         // ex: schema = [ "null", "int", "string" ], possible payloads = null, { "string": "str" },  { "int": 123 }
         // ex: schema = [ "null", "int", "string" ], possible payloads = null, { "string": "str" },  { "int": 123 }
-        if (node.isNull() && avroSchema.getTypes().contains(Schema.create(Schema.Type.NULL))) {
+        if (node.isNull() && avroSchema.getTypes().contains(NULL_SCHEMA)) {
           yield null;
           yield null;
         }
         }
 
 
         assertJsonType(node, JsonNodeType.OBJECT);
         assertJsonType(node, JsonNodeType.OBJECT);
         var elements = Lists.newArrayList(node.fields());
         var elements = Lists.newArrayList(node.fields());
         if (elements.size() != 1) {
         if (elements.size() != 1) {
-          throw new JsonToAvroConversionException(
+          throw new JsonAvroConversionException(
               "UNION field value should be an object with single field == type name");
               "UNION field value should be an object with single field == type name");
         }
         }
-        var typeNameToValue = elements.get(0);
+        Map.Entry<String, JsonNode> typeNameToValue = elements.get(0);
+        List<Schema> candidates = new ArrayList<>();
         for (Schema unionType : avroSchema.getTypes()) {
         for (Schema unionType : avroSchema.getTypes()) {
           if (typeNameToValue.getKey().equals(unionType.getFullName())) {
           if (typeNameToValue.getKey().equals(unionType.getFullName())) {
             yield convert(typeNameToValue.getValue(), unionType);
             yield convert(typeNameToValue.getValue(), unionType);
           }
           }
+          if (typeNameToValue.getKey().equals(unionType.getName())) {
+            candidates.add(unionType);
+          }
+        }
+        if (candidates.size() == 1) {
+          yield convert(typeNameToValue.getValue(), candidates.get(0));
         }
         }
-        throw new JsonToAvroConversionException(
+        if (candidates.size() > 1) {
+          throw new JsonAvroConversionException(
+              "Can't select type within union for value '%s'. Provide full type name.".formatted(node)
+          );
+        }
+        throw new JsonAvroConversionException(
             "json value '%s' is cannot be converted to any of union types [%s]"
             "json value '%s' is cannot be converted to any of union types [%s]"
                 .formatted(node, avroSchema.getTypes()));
                 .formatted(node, avroSchema.getTypes()));
       }
       }
@@ -164,7 +181,7 @@ public class JsonAvroConversion {
         assertJsonType(node, JsonNodeType.STRING);
         assertJsonType(node, JsonNodeType.STRING);
         byte[] bytes = node.textValue().getBytes(StandardCharsets.ISO_8859_1);
         byte[] bytes = node.textValue().getBytes(StandardCharsets.ISO_8859_1);
         if (bytes.length != avroSchema.getFixedSize()) {
         if (bytes.length != avroSchema.getFixedSize()) {
-          throw new JsonToAvroConversionException(
+          throw new JsonAvroConversionException(
               "Fixed field has unexpected size %d (should be %d)"
               "Fixed field has unexpected size %d (should be %d)"
                   .formatted(bytes.length, avroSchema.getFixedSize()));
                   .formatted(bytes.length, avroSchema.getFixedSize()));
         }
         }
@@ -208,8 +225,11 @@ public class JsonAvroConversion {
       case UNION -> {
       case UNION -> {
         ObjectNode node = MAPPER.createObjectNode();
         ObjectNode node = MAPPER.createObjectNode();
         int unionIdx = AvroData.getGenericData().resolveUnion(avroSchema, obj);
         int unionIdx = AvroData.getGenericData().resolveUnion(avroSchema, obj);
-        Schema unionType = avroSchema.getTypes().get(unionIdx);
-        node.set(unionType.getFullName(), convertAvroToJson(obj, unionType));
+        Schema selectedType = avroSchema.getTypes().get(unionIdx);
+        node.set(
+            selectUnionTypeFieldName(avroSchema, selectedType, unionIdx),
+            convertAvroToJson(obj, selectedType)
+        );
         yield node;
         yield node;
       }
       }
       case STRING -> {
       case STRING -> {
@@ -252,11 +272,30 @@ public class JsonAvroConversion {
     };
     };
   }
   }
 
 
+  // select name for a key field that represents type name of union.
+  // For records selects short name, if it is possible.
+  private static String selectUnionTypeFieldName(Schema unionSchema,
+                                                 Schema chosenType,
+                                                 int chosenTypeIdx) {
+    var types = unionSchema.getTypes();
+    if (types.size() == 2 && types.contains(NULL_SCHEMA)) {
+      return chosenType.getName();
+    }
+    for (int i = 0; i < types.size(); i++) {
+      if (i != chosenTypeIdx && chosenType.getName().equals(types.get(i).getName())) {
+        // there is another type inside union with the same name
+        // so, we have to use fullname
+        return chosenType.getFullName();
+      }
+    }
+    return chosenType.getName();
+  }
+
   private static Object processLogicalType(JsonNode node, Schema schema) {
   private static Object processLogicalType(JsonNode node, Schema schema) {
     return findConversion(schema)
     return findConversion(schema)
         .map(c -> c.jsonToAvroConversion.apply(node, schema))
         .map(c -> c.jsonToAvroConversion.apply(node, schema))
         .orElseThrow(() ->
         .orElseThrow(() ->
-            new JsonToAvroConversionException("'%s' logical type is not supported"
+            new JsonAvroConversionException("'%s' logical type is not supported"
                 .formatted(schema.getLogicalType().getName())));
                 .formatted(schema.getLogicalType().getName())));
   }
   }
 
 
@@ -264,7 +303,7 @@ public class JsonAvroConversion {
     return findConversion(schema)
     return findConversion(schema)
         .map(c -> c.avroToJsonConversion.apply(obj, schema))
         .map(c -> c.avroToJsonConversion.apply(obj, schema))
         .orElseThrow(() ->
         .orElseThrow(() ->
-            new JsonToAvroConversionException("'%s' logical type is not supported"
+            new JsonAvroConversionException("'%s' logical type is not supported"
                 .formatted(schema.getLogicalType().getName())));
                 .formatted(schema.getLogicalType().getName())));
   }
   }
 
 
@@ -281,7 +320,7 @@ public class JsonAvroConversion {
 
 
   private static void assertJsonType(JsonNode node, JsonNodeType... allowedTypes) {
   private static void assertJsonType(JsonNode node, JsonNodeType... allowedTypes) {
     if (Stream.of(allowedTypes).noneMatch(t -> node.getNodeType() == t)) {
     if (Stream.of(allowedTypes).noneMatch(t -> node.getNodeType() == t)) {
-      throw new JsonToAvroConversionException(
+      throw new JsonAvroConversionException(
           "%s node has unexpected type, allowed types %s, actual type %s"
           "%s node has unexpected type, allowed types %s, actual type %s"
               .formatted(node, Arrays.toString(allowedTypes), node.getNodeType()));
               .formatted(node, Arrays.toString(allowedTypes), node.getNodeType()));
     }
     }
@@ -289,7 +328,7 @@ public class JsonAvroConversion {
 
 
   private static void assertJsonNumberType(JsonNode node, JsonParser.NumberType... allowedTypes) {
   private static void assertJsonNumberType(JsonNode node, JsonParser.NumberType... allowedTypes) {
     if (Stream.of(allowedTypes).noneMatch(t -> node.numberType() == t)) {
     if (Stream.of(allowedTypes).noneMatch(t -> node.numberType() == t)) {
-      throw new JsonToAvroConversionException(
+      throw new JsonAvroConversionException(
           "%s node has unexpected numeric type, allowed types %s, actual type %s"
           "%s node has unexpected numeric type, allowed types %s, actual type %s"
               .formatted(node, Arrays.toString(allowedTypes), node.numberType()));
               .formatted(node, Arrays.toString(allowedTypes), node.numberType()));
     }
     }
@@ -318,7 +357,7 @@ public class JsonAvroConversion {
           } else if (node.isNumber()) {
           } else if (node.isNumber()) {
             return new BigDecimal(node.numberValue().toString());
             return new BigDecimal(node.numberValue().toString());
           }
           }
-          throw new JsonToAvroConversionException(
+          throw new JsonAvroConversionException(
               "node '%s' can't be converted to decimal logical type"
               "node '%s' can't be converted to decimal logical type"
                   .formatted(node));
                   .formatted(node));
         },
         },
@@ -335,7 +374,7 @@ public class JsonAvroConversion {
           } else if (node.isTextual()) {
           } else if (node.isTextual()) {
             return LocalDate.parse(node.asText());
             return LocalDate.parse(node.asText());
           } else {
           } else {
-            throw new JsonToAvroConversionException(
+            throw new JsonAvroConversionException(
                 "node '%s' can't be converted to date logical type"
                 "node '%s' can't be converted to date logical type"
                     .formatted(node));
                     .formatted(node));
           }
           }
@@ -356,7 +395,7 @@ public class JsonAvroConversion {
           } else if (node.isTextual()) {
           } else if (node.isTextual()) {
             return LocalTime.parse(node.asText());
             return LocalTime.parse(node.asText());
           } else {
           } else {
-            throw new JsonToAvroConversionException(
+            throw new JsonAvroConversionException(
                 "node '%s' can't be converted to time-millis logical type"
                 "node '%s' can't be converted to time-millis logical type"
                     .formatted(node));
                     .formatted(node));
           }
           }
@@ -377,7 +416,7 @@ public class JsonAvroConversion {
           } else if (node.isTextual()) {
           } else if (node.isTextual()) {
             return LocalTime.parse(node.asText());
             return LocalTime.parse(node.asText());
           } else {
           } else {
-            throw new JsonToAvroConversionException(
+            throw new JsonAvroConversionException(
                 "node '%s' can't be converted to time-micros logical type"
                 "node '%s' can't be converted to time-micros logical type"
                     .formatted(node));
                     .formatted(node));
           }
           }
@@ -398,7 +437,7 @@ public class JsonAvroConversion {
           } else if (node.isTextual()) {
           } else if (node.isTextual()) {
             return Instant.parse(node.asText());
             return Instant.parse(node.asText());
           } else {
           } else {
-            throw new JsonToAvroConversionException(
+            throw new JsonAvroConversionException(
                 "node '%s' can't be converted to timestamp-millis logical type"
                 "node '%s' can't be converted to timestamp-millis logical type"
                     .formatted(node));
                     .formatted(node));
           }
           }
@@ -423,7 +462,7 @@ public class JsonAvroConversion {
           } else if (node.isTextual()) {
           } else if (node.isTextual()) {
             return Instant.parse(node.asText());
             return Instant.parse(node.asText());
           } else {
           } else {
-            throw new JsonToAvroConversionException(
+            throw new JsonAvroConversionException(
                 "node '%s' can't be converted to timestamp-millis logical type"
                 "node '%s' can't be converted to timestamp-millis logical type"
                     .formatted(node));
                     .formatted(node));
           }
           }

+ 3 - 0
kafka-ui-api/src/main/resources/application-local.yml

@@ -144,3 +144,6 @@ rbac:
 
 
         - resource: acl
         - resource: acl
           actions: all
           actions: all
+
+        - resource: audit
+          actions: all

+ 2 - 0
kafka-ui-api/src/test/java/com/provectus/kafka/ui/AbstractIntegrationTest.java

@@ -74,6 +74,8 @@ public abstract class AbstractIntegrationTest {
       System.setProperty("kafka.clusters.0.masking.0.type", "REPLACE");
       System.setProperty("kafka.clusters.0.masking.0.type", "REPLACE");
       System.setProperty("kafka.clusters.0.masking.0.replacement", "***");
       System.setProperty("kafka.clusters.0.masking.0.replacement", "***");
       System.setProperty("kafka.clusters.0.masking.0.topicValuesPattern", "masking-test-.*");
       System.setProperty("kafka.clusters.0.masking.0.topicValuesPattern", "masking-test-.*");
+      System.setProperty("kafka.clusters.0.audit.topicAuditEnabled", "true");
+      System.setProperty("kafka.clusters.0.audit.consoleAuditEnabled", "true");
 
 
       System.setProperty("kafka.clusters.1.name", SECOND_LOCAL);
       System.setProperty("kafka.clusters.1.name", SECOND_LOCAL);
       System.setProperty("kafka.clusters.1.readOnly", "true");
       System.setProperty("kafka.clusters.1.readOnly", "true");

+ 185 - 0
kafka-ui-api/src/test/java/com/provectus/kafka/ui/serdes/builtin/ConsumerOffsetsSerdeTest.java

@@ -0,0 +1,185 @@
+package com.provectus.kafka.ui.serdes.builtin;
+
+import static com.provectus.kafka.ui.serdes.builtin.ConsumerOffsetsSerde.TOPIC;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.provectus.kafka.ui.AbstractIntegrationTest;
+import com.provectus.kafka.ui.producer.KafkaTestProducer;
+import com.provectus.kafka.ui.serde.api.DeserializeResult;
+import com.provectus.kafka.ui.serde.api.Serde;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.UUID;
+import lombok.SneakyThrows;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.common.serialization.BytesDeserializer;
+import org.apache.kafka.common.utils.Bytes;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.shaded.org.awaitility.Awaitility;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
+
+class ConsumerOffsetsSerdeTest extends AbstractIntegrationTest {
+
+  private static final int MSGS_TO_GENERATE = 10;
+
+  private static String consumerGroupName;
+  private static String committedTopic;
+
+  @BeforeAll
+  static void createTopicAndCommitItsOffset() {
+    committedTopic = ConsumerOffsetsSerdeTest.class.getSimpleName() + "-" + UUID.randomUUID();
+    consumerGroupName = committedTopic + "-group";
+    createTopic(new NewTopic(committedTopic, 1, (short) 1));
+
+    try (var producer = KafkaTestProducer.forKafka(kafka)) {
+      for (int i = 0; i < MSGS_TO_GENERATE; i++) {
+        producer.send(committedTopic, "i=" + i);
+      }
+    }
+    try (var consumer = createConsumer(consumerGroupName)) {
+      consumer.subscribe(List.of(committedTopic));
+      int polled = 0;
+      while (polled < MSGS_TO_GENERATE) {
+        polled += consumer.poll(Duration.ofMillis(100)).count();
+      }
+      consumer.commitSync();
+    }
+  }
+
+  @AfterAll
+  static void cleanUp() {
+    deleteTopic(committedTopic);
+  }
+
+  @Test
+  void canOnlyDeserializeConsumerOffsetsTopic() {
+    var serde = new ConsumerOffsetsSerde();
+    assertThat(serde.canDeserialize(ConsumerOffsetsSerde.TOPIC, Serde.Target.KEY)).isTrue();
+    assertThat(serde.canDeserialize(ConsumerOffsetsSerde.TOPIC, Serde.Target.VALUE)).isTrue();
+    assertThat(serde.canDeserialize("anyOtherTopic", Serde.Target.KEY)).isFalse();
+    assertThat(serde.canDeserialize("anyOtherTopic", Serde.Target.VALUE)).isFalse();
+  }
+
+  @Test
+  void deserializesMessagesMadeByConsumerActivity() {
+    var serde = new ConsumerOffsetsSerde();
+    var keyDeserializer = serde.deserializer(TOPIC, Serde.Target.KEY);
+    var valueDeserializer = serde.deserializer(TOPIC, Serde.Target.VALUE);
+
+    try (var consumer = createConsumer(consumerGroupName + "-check")) {
+      consumer.subscribe(List.of(ConsumerOffsetsSerde.TOPIC));
+      List<Tuple2<DeserializeResult, DeserializeResult>> polled = new ArrayList<>();
+
+      Awaitility.await()
+          .pollInSameThread()
+          .atMost(Duration.ofMinutes(1))
+          .untilAsserted(() -> {
+            for (var rec : consumer.poll(Duration.ofMillis(200))) {
+              DeserializeResult key = rec.key() != null
+                  ? keyDeserializer.deserialize(null, rec.key().get())
+                  : null;
+              DeserializeResult val = rec.value() != null
+                  ? valueDeserializer.deserialize(null, rec.value().get())
+                  : null;
+              if (key != null && val != null) {
+                polled.add(Tuples.of(key, val));
+              }
+            }
+            assertThat(polled).anyMatch(t -> isCommitMessage(t.getT1(), t.getT2()));
+            assertThat(polled).anyMatch(t -> isGroupMetadataMessage(t.getT1(), t.getT2()));
+          });
+    }
+  }
+
+  // Sample commit record:
+  //
+  // key: {
+  //  "group": "test_Members_3",
+  //  "topic": "test",
+  //  "partition": 0
+  // }
+  //
+  // value:
+  // {
+  //  "offset": 2,
+  //  "leader_epoch": 0,
+  //  "metadata": "",
+  //  "commit_timestamp": 1683112980588
+  // }
+  private boolean isCommitMessage(DeserializeResult key, DeserializeResult value) {
+    var keyJson = toMapFromJsom(key);
+    boolean keyIsOk = consumerGroupName.equals(keyJson.get("group"))
+        && committedTopic.equals(keyJson.get("topic"))
+        && ((Integer) 0).equals(keyJson.get("partition"));
+
+    var valueJson = toMapFromJsom(value);
+    boolean valueIsOk = valueJson.containsKey("offset")
+        && valueJson.get("offset").equals(MSGS_TO_GENERATE)
+        && valueJson.containsKey("commit_timestamp");
+
+    return keyIsOk && valueIsOk;
+  }
+
+  // Sample group metadata record:
+  //
+  // key: {
+  //  "group": "test_Members_3"
+  // }
+  //
+  // value:
+  // {
+  //  "protocol_type": "consumer",
+  //  "generation": 1,
+  //  "protocol": "range",
+  //  "leader": "consumer-test_Members_3-1-5a37876e-e42f-420e-9c7d-6902889bd5dd",
+  //  "current_state_timestamp": 1683112974561,
+  //  "members": [
+  //    {
+  //      "member_id": "consumer-test_Members_3-1-5a37876e-e42f-420e-9c7d-6902889bd5dd",
+  //      "group_instance_id": null,
+  //      "client_id": "consumer-test_Members_3-1",
+  //      "client_host": "/192.168.16.1",
+  //      "rebalance_timeout": 300000,
+  //      "session_timeout": 45000,
+  //      "subscription": "AAEAAAABAAR0ZXN0/////wAAAAA=",
+  //      "assignment": "AAEAAAABAAR0ZXN0AAAAAQAAAAD/////"
+  //    }
+  //  ]
+  // }
+  private boolean isGroupMetadataMessage(DeserializeResult key, DeserializeResult value) {
+    var keyJson = toMapFromJsom(key);
+    boolean keyIsOk = consumerGroupName.equals(keyJson.get("group")) && keyJson.size() == 1;
+
+    var valueJson = toMapFromJsom(value);
+    boolean valueIsOk = valueJson.keySet()
+        .containsAll(Set.of("protocol_type", "generation", "leader", "members"));
+
+    return keyIsOk && valueIsOk;
+  }
+
+  @SneakyThrows
+  private Map<String, Object> toMapFromJsom(DeserializeResult result) {
+    return new JsonMapper().readValue(result.getResult(), Map.class);
+  }
+
+  private static KafkaConsumer<Bytes, Bytes> createConsumer(String groupId) {
+    Properties props = new Properties();
+    props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+    props.put(ConsumerConfig.CLIENT_ID_CONFIG, groupId);
+    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
+    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, BytesDeserializer.class);
+    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, BytesDeserializer.class);
+    props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+    return new KafkaConsumer<>(props);
+  }
+}

+ 1 - 14
kafka-ui-api/src/test/java/com/provectus/kafka/ui/serdes/builtin/ProtobufFileSerdeTest.java

@@ -47,7 +47,6 @@ class ProtobufFileSerdeTest {
   @BeforeEach
   @BeforeEach
   void setUp() throws Exception {
   void setUp() throws Exception {
     Map<Path, ProtobufSchema> files = ProtobufFileSerde.Configuration.loadSchemas(
     Map<Path, ProtobufSchema> files = ProtobufFileSerde.Configuration.loadSchemas(
-        Optional.empty(),
         Optional.empty(),
         Optional.empty(),
         Optional.of(protoFilesDir())
         Optional.of(protoFilesDir())
     );
     );
@@ -107,15 +106,6 @@ class ProtobufFileSerdeTest {
           .isFalse();
           .isFalse();
     }
     }
 
 
-    @Test
-    void canBeAutoConfiguredReturnsTrueIfNoProtoFileHasBeenProvided() {
-      PropertyResolver resolver = mock(PropertyResolver.class);
-      when(resolver.getProperty("protobufFile", String.class))
-          .thenReturn(Optional.of("file.proto"));
-      assertThat(Configuration.canBeAutoConfigured(resolver))
-          .isTrue();
-    }
-
     @Test
     @Test
     void canBeAutoConfiguredReturnsTrueIfProtoFilesHasBeenProvided() {
     void canBeAutoConfiguredReturnsTrueIfProtoFilesHasBeenProvided() {
       PropertyResolver resolver = mock(PropertyResolver.class);
       PropertyResolver resolver = mock(PropertyResolver.class);
@@ -193,13 +183,10 @@ class ProtobufFileSerdeTest {
     @Test
     @Test
     void createConfigureFillsDescriptorMappingsWhenProtoFilesListProvided() throws Exception {
     void createConfigureFillsDescriptorMappingsWhenProtoFilesListProvided() throws Exception {
       PropertyResolver resolver = mock(PropertyResolver.class);
       PropertyResolver resolver = mock(PropertyResolver.class);
-      when(resolver.getProperty("protobufFile", String.class))
-          .thenReturn(Optional.of(
-              ResourceUtils.getFile("classpath:protobuf-serde/sensor.proto").getPath()));
-
       when(resolver.getListProperty("protobufFiles", String.class))
       when(resolver.getListProperty("protobufFiles", String.class))
           .thenReturn(Optional.of(
           .thenReturn(Optional.of(
               List.of(
               List.of(
+                  ResourceUtils.getFile("classpath:protobuf-serde/sensor.proto").getPath(),
                   ResourceUtils.getFile("classpath:protobuf-serde/address-book.proto").getPath())));
                   ResourceUtils.getFile("classpath:protobuf-serde/address-book.proto").getPath())));
 
 
       when(resolver.getProperty("protobufMessageName", String.class))
       when(resolver.getProperty("protobufMessageName", String.class))

+ 41 - 0
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/MessagesServiceTest.java

@@ -1,5 +1,8 @@
 package com.provectus.kafka.ui.service;
 package com.provectus.kafka.ui.service;
 
 
+import static com.provectus.kafka.ui.service.MessagesService.execSmartFilterTest;
+import static org.assertj.core.api.Assertions.assertThat;
+
 import com.provectus.kafka.ui.AbstractIntegrationTest;
 import com.provectus.kafka.ui.AbstractIntegrationTest;
 import com.provectus.kafka.ui.exception.TopicNotFoundException;
 import com.provectus.kafka.ui.exception.TopicNotFoundException;
 import com.provectus.kafka.ui.model.ConsumerPosition;
 import com.provectus.kafka.ui.model.ConsumerPosition;
@@ -7,11 +10,13 @@ import com.provectus.kafka.ui.model.CreateTopicMessageDTO;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.SeekDirectionDTO;
 import com.provectus.kafka.ui.model.SeekDirectionDTO;
 import com.provectus.kafka.ui.model.SeekTypeDTO;
 import com.provectus.kafka.ui.model.SeekTypeDTO;
+import com.provectus.kafka.ui.model.SmartFilterTestExecutionDTO;
 import com.provectus.kafka.ui.model.TopicMessageDTO;
 import com.provectus.kafka.ui.model.TopicMessageDTO;
 import com.provectus.kafka.ui.model.TopicMessageEventDTO;
 import com.provectus.kafka.ui.model.TopicMessageEventDTO;
 import com.provectus.kafka.ui.producer.KafkaTestProducer;
 import com.provectus.kafka.ui.producer.KafkaTestProducer;
 import com.provectus.kafka.ui.serdes.builtin.StringSerde;
 import com.provectus.kafka.ui.serdes.builtin.StringSerde;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
 import java.util.UUID;
 import org.apache.kafka.clients.admin.NewTopic;
 import org.apache.kafka.clients.admin.NewTopic;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -91,4 +96,40 @@ class MessagesServiceTest extends AbstractIntegrationTest {
     }
     }
   }
   }
 
 
+  @Test
+  void execSmartFilterTestReturnsExecutionResult() {
+    var params = new SmartFilterTestExecutionDTO()
+        .filterCode("key != null && value != null && headers != null && timestampMs != null && offset != null")
+        .key("1234")
+        .value("{ \"some\" : \"value\" } ")
+        .headers(Map.of("h1", "hv1"))
+        .offset(12345L)
+        .timestampMs(System.currentTimeMillis())
+        .partition(1);
+    assertThat(execSmartFilterTest(params).getResult()).isTrue();
+
+    params.setFilterCode("return false");
+    assertThat(execSmartFilterTest(params).getResult()).isFalse();
+  }
+
+  @Test
+  void execSmartFilterTestReturnsErrorOnFilterApplyError() {
+    var result = execSmartFilterTest(
+        new SmartFilterTestExecutionDTO()
+            .filterCode("return 1/0")
+    );
+    assertThat(result.getResult()).isNull();
+    assertThat(result.getError()).containsIgnoringCase("execution error");
+  }
+
+  @Test
+  void execSmartFilterTestReturnsErrorOnFilterCompilationError() {
+    var result = execSmartFilterTest(
+        new SmartFilterTestExecutionDTO()
+            .filterCode("this is invalid groovy syntax = 1")
+    );
+    assertThat(result.getResult()).isNull();
+    assertThat(result.getError()).containsIgnoringCase("Compilation error");
+  }
+
 }
 }

+ 3 - 1
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java

@@ -9,6 +9,7 @@ import static org.mockito.Mockito.when;
 import com.provectus.kafka.ui.controller.SchemasController;
 import com.provectus.kafka.ui.controller.SchemasController;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.SchemaSubjectDTO;
 import com.provectus.kafka.ui.model.SchemaSubjectDTO;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.sr.model.Compatibility;
 import com.provectus.kafka.ui.sr.model.Compatibility;
 import com.provectus.kafka.ui.sr.model.SchemaSubject;
 import com.provectus.kafka.ui.sr.model.SchemaSubject;
 import com.provectus.kafka.ui.util.AccessControlServiceMock;
 import com.provectus.kafka.ui.util.AccessControlServiceMock;
@@ -41,7 +42,8 @@ public class SchemaRegistryPaginationTest {
                 new SchemaRegistryService.SubjectWithCompatibilityLevel(
                 new SchemaRegistryService.SubjectWithCompatibilityLevel(
                     new SchemaSubject().subject(a.getArgument(1)), Compatibility.FULL)));
                     new SchemaSubject().subject(a.getArgument(1)), Compatibility.FULL)));
 
 
-    this.controller = new SchemasController(schemaRegistryService, new AccessControlServiceMock().getMock());
+    this.controller = new SchemasController(schemaRegistryService, new AccessControlServiceMock().getMock(),
+        mock(AuditService.class));
     this.controller.setClustersStorage(clustersStorage);
     this.controller.setClustersStorage(clustersStorage);
   }
   }
 
 

+ 3 - 3
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/TopicsServicePaginationTest.java

@@ -18,6 +18,7 @@ import com.provectus.kafka.ui.model.SortOrderDTO;
 import com.provectus.kafka.ui.model.TopicColumnsToSortDTO;
 import com.provectus.kafka.ui.model.TopicColumnsToSortDTO;
 import com.provectus.kafka.ui.model.TopicDTO;
 import com.provectus.kafka.ui.model.TopicDTO;
 import com.provectus.kafka.ui.service.analyze.TopicAnalysisService;
 import com.provectus.kafka.ui.service.analyze.TopicAnalysisService;
+import com.provectus.kafka.ui.service.audit.AuditService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.util.AccessControlServiceMock;
 import com.provectus.kafka.ui.util.AccessControlServiceMock;
 import java.util.ArrayList;
 import java.util.ArrayList;
@@ -33,7 +34,6 @@ import java.util.stream.IntStream;
 import org.apache.kafka.clients.admin.TopicDescription;
 import org.apache.kafka.clients.admin.TopicDescription;
 import org.apache.kafka.common.TopicPartitionInfo;
 import org.apache.kafka.common.TopicPartitionInfo;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
-import org.springframework.test.util.ReflectionTestUtils;
 import reactor.core.publisher.Mono;
 import reactor.core.publisher.Mono;
 
 
 class TopicsServicePaginationTest {
 class TopicsServicePaginationTest {
@@ -46,7 +46,7 @@ class TopicsServicePaginationTest {
   private final AccessControlService accessControlService = new AccessControlServiceMock().getMock();
   private final AccessControlService accessControlService = new AccessControlServiceMock().getMock();
 
 
   private final TopicsController topicsController = new TopicsController(
   private final TopicsController topicsController = new TopicsController(
-      topicsService, mock(TopicAnalysisService.class), clusterMapper, accessControlService);
+      topicsService, mock(TopicAnalysisService.class), clusterMapper, accessControlService, mock(AuditService.class));
 
 
   private void init(Map<String, InternalTopic> topicsInCache) {
   private void init(Map<String, InternalTopic> topicsInCache) {
 
 
@@ -59,7 +59,7 @@ class TopicsServicePaginationTest {
           List<String> lst = a.getArgument(1);
           List<String> lst = a.getArgument(1);
           return Mono.just(lst.stream().map(topicsInCache::get).collect(Collectors.toList()));
           return Mono.just(lst.stream().map(topicsInCache::get).collect(Collectors.toList()));
         });
         });
-    ReflectionTestUtils.setField(topicsController, "clustersStorage", clustersStorage);
+    topicsController.setClustersStorage(clustersStorage);
   }
   }
 
 
   @Test
   @Test

+ 87 - 0
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/audit/AuditIntegrationTest.java

@@ -0,0 +1,87 @@
+package com.provectus.kafka.ui.service.audit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.provectus.kafka.ui.AbstractIntegrationTest;
+import com.provectus.kafka.ui.model.TopicCreationDTO;
+import com.provectus.kafka.ui.model.rbac.Resource;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.UUID;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.common.serialization.BytesDeserializer;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.testcontainers.shaded.org.awaitility.Awaitility;
+
+public class AuditIntegrationTest extends AbstractIntegrationTest {
+
+  @Autowired
+  private WebTestClient webTestClient;
+
+  @Test
+  void auditRecordWrittenIntoKafkaWhenNewTopicCreated() {
+    String newTopicName = "test_audit_" + UUID.randomUUID();
+
+    webTestClient.post()
+        .uri("/api/clusters/{clusterName}/topics", LOCAL)
+        .bodyValue(
+            new TopicCreationDTO()
+                .replicationFactor(1)
+                .partitions(1)
+                .name(newTopicName)
+        )
+        .exchange()
+        .expectStatus()
+        .isOk();
+
+    try (var consumer = createConsumer()) {
+      var jsonMapper = new JsonMapper();
+      consumer.subscribe(List.of("__kui-audit-log"));
+      Awaitility.await()
+          .pollInSameThread()
+          .atMost(Duration.ofSeconds(15))
+          .untilAsserted(() -> {
+            var polled = consumer.poll(Duration.ofSeconds(1));
+            assertThat(polled).anySatisfy(kafkaRecord -> {
+              try {
+                AuditRecord record = jsonMapper.readValue(kafkaRecord.value(), AuditRecord.class);
+                assertThat(record.operation()).isEqualTo("createTopic");
+                assertThat(record.resources()).map(AuditRecord.AuditResource::type).contains(Resource.TOPIC);
+                assertThat(record.result().success()).isTrue();
+                assertThat(record.timestamp()).isNotBlank();
+                assertThat(record.clusterName()).isEqualTo(LOCAL);
+                assertThat(record.operationParams())
+                    .isEqualTo(Map.of(
+                        "name", newTopicName,
+                        "partitions", 1,
+                        "replicationFactor", 1,
+                        "configs", Map.of()
+                    ));
+              } catch (JsonProcessingException e) {
+                Assertions.fail();
+              }
+            });
+          });
+    }
+  }
+
+  private KafkaConsumer<?, String> createConsumer() {
+    Properties props = new Properties();
+    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
+    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, BytesDeserializer.class);
+    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+    props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+    props.put(ConsumerConfig.GROUP_ID_CONFIG, AuditIntegrationTest.class.getName());
+    return new KafkaConsumer<>(props);
+  }
+
+}

+ 154 - 0
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/audit/AuditServiceTest.java

@@ -0,0 +1,154 @@
+package com.provectus.kafka.ui.service.audit;
+
+import static com.provectus.kafka.ui.service.audit.AuditService.createAuditWriter;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.provectus.kafka.ui.config.ClustersProperties;
+import com.provectus.kafka.ui.model.KafkaCluster;
+import com.provectus.kafka.ui.model.rbac.AccessContext;
+import com.provectus.kafka.ui.service.ReactiveAdminClient;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Signal;
+
+class AuditServiceTest {
+
+  @Test
+  void isAuditTopicChecksIfAuditIsEnabledForCluster() {
+    Map<String, AuditWriter> writers = Map.of(
+        "c1", new AuditWriter("с1", "c1topic", null, null),
+        "c2", new AuditWriter("c2", "c2topic", mock(KafkaProducer.class), null)
+    );
+
+    var auditService = new AuditService(writers);
+    assertThat(auditService.isAuditTopic(KafkaCluster.builder().name("notExist").build(), "some"))
+        .isFalse();
+    assertThat(auditService.isAuditTopic(KafkaCluster.builder().name("c1").build(), "c1topic"))
+        .isFalse();
+    assertThat(auditService.isAuditTopic(KafkaCluster.builder().name("c2").build(), "c2topic"))
+        .isTrue();
+  }
+
+  @Test
+  void auditCallsWriterMethodDependingOnSignal() {
+    var auditWriter = mock(AuditWriter.class);
+    var auditService = new AuditService(Map.of("test", auditWriter));
+
+    var cxt = AccessContext.builder().cluster("test").build();
+
+    auditService.audit(cxt, Signal.complete());
+    verify(auditWriter).write(any(), any(), eq(null));
+
+    var th = new Exception("testError");
+    auditService.audit(cxt, Signal.error(th));
+    verify(auditWriter).write(any(), any(), eq(th));
+  }
+
+  @Nested
+  class CreateAuditWriter {
+
+    private final ReactiveAdminClient adminClientMock = mock(ReactiveAdminClient.class);
+    private final Supplier<KafkaProducer<byte[], byte[]>> producerSupplierMock = mock(Supplier.class);
+
+    private final ClustersProperties.Cluster clustersProperties = new ClustersProperties.Cluster();
+
+    private final KafkaCluster cluster = KafkaCluster
+        .builder()
+        .name("test")
+        .originalProperties(clustersProperties)
+        .build();
+
+
+    @BeforeEach
+    void init() {
+      when(producerSupplierMock.get())
+          .thenReturn(mock(KafkaProducer.class));
+    }
+
+    @Test
+    void noWriterIfNoAuditPropsSet() {
+      var maybeWriter = createAuditWriter(cluster, adminClientMock, producerSupplierMock);
+      assertThat(maybeWriter).isEmpty();
+    }
+
+    @Test
+    void setsLoggerIfConsoleLoggingEnabled() {
+      var auditProps = new ClustersProperties.AuditProperties();
+      auditProps.setConsoleAuditEnabled(true);
+      clustersProperties.setAudit(auditProps);
+
+      var maybeWriter = createAuditWriter(cluster, adminClientMock, producerSupplierMock);
+      assertThat(maybeWriter).isPresent();
+
+      var writer = maybeWriter.get();
+      assertThat(writer.consoleLogger()).isNotNull();
+    }
+
+    @Nested
+    class WhenTopicAuditEnabled {
+
+      @BeforeEach
+      void setTopicWriteProperties() {
+        var auditProps = new ClustersProperties.AuditProperties();
+        auditProps.setTopicAuditEnabled(true);
+        auditProps.setTopic("test_audit_topic");
+        auditProps.setAuditTopicsPartitions(3);
+        auditProps.setAuditTopicProperties(Map.of("p1", "v1"));
+        clustersProperties.setAudit(auditProps);
+      }
+
+      @Test
+      void createsProducerIfTopicExists() {
+        when(adminClientMock.listTopics(true))
+            .thenReturn(Mono.just(Set.of("test_audit_topic")));
+
+        var maybeWriter = createAuditWriter(cluster, adminClientMock, producerSupplierMock);
+        assertThat(maybeWriter).isPresent();
+
+        //checking there was no topic creation request
+        verify(adminClientMock, times(0))
+            .createTopic(any(), anyInt(), anyInt(), anyMap());
+
+        var writer = maybeWriter.get();
+        assertThat(writer.producer()).isNotNull();
+        assertThat(writer.targetTopic()).isEqualTo("test_audit_topic");
+      }
+
+      @Test
+      void createsProducerAndTopicIfItIsNotExist() {
+        when(adminClientMock.listTopics(true))
+            .thenReturn(Mono.just(Set.of()));
+
+        when(adminClientMock.createTopic(eq("test_audit_topic"), eq(3), eq(null), anyMap()))
+            .thenReturn(Mono.empty());
+
+        var maybeWriter = createAuditWriter(cluster, adminClientMock, producerSupplierMock);
+        assertThat(maybeWriter).isPresent();
+
+        //verifying topic created
+        verify(adminClientMock).createTopic(eq("test_audit_topic"), eq(3), eq(null), anyMap());
+
+        var writer = maybeWriter.get();
+        assertThat(writer.producer()).isNotNull();
+        assertThat(writer.targetTopic()).isEqualTo("test_audit_topic");
+      }
+
+    }
+  }
+
+
+}

+ 3 - 1
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/integration/odd/TopicsExporterTest.java

@@ -24,6 +24,8 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.opendatadiscovery.client.model.DataEntity;
 import org.opendatadiscovery.client.model.DataEntity;
 import org.opendatadiscovery.client.model.DataEntityType;
 import org.opendatadiscovery.client.model.DataEntityType;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
 import reactor.core.publisher.Mono;
 import reactor.core.publisher.Mono;
 import reactor.test.StepVerifier;
 import reactor.test.StepVerifier;
 
 
@@ -55,7 +57,7 @@ class TopicsExporterTest {
   @Test
   @Test
   void doesNotExportTopicsWhichDontFitFiltrationRule() {
   void doesNotExportTopicsWhichDontFitFiltrationRule() {
     when(schemaRegistryClientMock.getSubjectVersion(anyString(), anyString()))
     when(schemaRegistryClientMock.getSubjectVersion(anyString(), anyString()))
-        .thenReturn(Mono.error(new RuntimeException("Not found")));
+        .thenReturn(Mono.error(WebClientResponseException.create(404, "NF", new HttpHeaders(), null, null, null)));
 
 
     stats = Statistics.empty()
     stats = Statistics.empty()
         .toBuilder()
         .toBuilder()

+ 0 - 1
kafka-ui-api/src/test/java/com/provectus/kafka/ui/util/AccessControlServiceMock.java

@@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 
 
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
 import com.provectus.kafka.ui.service.rbac.AccessControlService;
-import java.util.Collections;
 import org.mockito.Mockito;
 import org.mockito.Mockito;
 import reactor.core.publisher.Mono;
 import reactor.core.publisher.Mono;
 
 

+ 94 - 2
kafka-ui-api/src/test/java/com/provectus/kafka/ui/util/jsonschema/JsonAvroConversionTest.java

@@ -3,6 +3,7 @@ package com.provectus.kafka.ui.util.jsonschema;
 import static com.provectus.kafka.ui.util.jsonschema.JsonAvroConversion.convertAvroToJson;
 import static com.provectus.kafka.ui.util.jsonschema.JsonAvroConversion.convertAvroToJson;
 import static com.provectus.kafka.ui.util.jsonschema.JsonAvroConversion.convertJsonToAvro;
 import static com.provectus.kafka.ui.util.jsonschema.JsonAvroConversion.convertJsonToAvro;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.json.JsonMapper;
 import com.fasterxml.jackson.databind.json.JsonMapper;
@@ -13,6 +14,7 @@ import com.fasterxml.jackson.databind.node.IntNode;
 import com.fasterxml.jackson.databind.node.LongNode;
 import com.fasterxml.jackson.databind.node.LongNode;
 import com.fasterxml.jackson.databind.node.TextNode;
 import com.fasterxml.jackson.databind.node.TextNode;
 import com.google.common.primitives.Longs;
 import com.google.common.primitives.Longs;
+import com.provectus.kafka.ui.exception.JsonAvroConversionException;
 import io.confluent.kafka.schemaregistry.avro.AvroSchema;
 import io.confluent.kafka.schemaregistry.avro.AvroSchema;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.nio.ByteBuffer;
 import java.nio.ByteBuffer;
@@ -181,12 +183,62 @@ class JsonAvroConversionTest {
       record = (GenericData.Record) convertJsonToAvro(jsonPayload, schema);
       record = (GenericData.Record) convertJsonToAvro(jsonPayload, schema);
       assertThat(record.get("f_union")).isEqualTo(123);
       assertThat(record.get("f_union")).isEqualTo(123);
 
 
-      //inner-record's name should be fully-qualified!
-      jsonPayload = "{ \"f_union\": { \"com.test.TestAvroRecord\": { \"f_union\": { \"int\": 123  } } } }";
+      //short name can be used since there is no clash with other type names
+      jsonPayload = "{ \"f_union\": { \"TestAvroRecord\": { \"f_union\": { \"int\": 123  } } } }";
       record = (GenericData.Record) convertJsonToAvro(jsonPayload, schema);
       record = (GenericData.Record) convertJsonToAvro(jsonPayload, schema);
       assertThat(record.get("f_union")).isInstanceOf(GenericData.Record.class);
       assertThat(record.get("f_union")).isInstanceOf(GenericData.Record.class);
       var innerRec = (GenericData.Record) record.get("f_union");
       var innerRec = (GenericData.Record) record.get("f_union");
       assertThat(innerRec.get("f_union")).isEqualTo(123);
       assertThat(innerRec.get("f_union")).isEqualTo(123);
+
+      assertThatThrownBy(() ->
+          convertJsonToAvro("{ \"f_union\": { \"NotExistingType\": 123 } }", schema)
+      ).isInstanceOf(JsonAvroConversionException.class);
+    }
+
+    @Test
+    void unionFieldWithTypeNamesClash() {
+      var schema = createSchema(
+          """
+               {
+                 "type": "record",
+                 "namespace": "com.test",
+                 "name": "TestAvroRecord",
+                 "fields": [
+                   {
+                     "name": "nestedClass",
+                     "type": {
+                       "type": "record",
+                       "namespace": "com.nested",
+                       "name": "TestAvroRecord",
+                       "fields": [
+                         {"name" : "inner_obj_field", "type": "int" }
+                       ]
+                     }
+                   },
+                   {
+                     "name": "f_union",
+                     "type": [ "null", "int", "com.test.TestAvroRecord", "com.nested.TestAvroRecord"]
+                   }
+                 ]
+              }"""
+      );
+      //short name can't can be used since there is a clash with other type names
+      var jsonPayload = "{ \"f_union\": { \"com.test.TestAvroRecord\": { \"f_union\": { \"int\": 123  } } } }";
+      var record = (GenericData.Record) convertJsonToAvro(jsonPayload, schema);
+      assertThat(record.get("f_union")).isInstanceOf(GenericData.Record.class);
+      var innerRec = (GenericData.Record) record.get("f_union");
+      assertThat(innerRec.get("f_union")).isEqualTo(123);
+
+      //short name can't can be used since there is a clash with other type names
+      jsonPayload = "{ \"f_union\": { \"com.nested.TestAvroRecord\": { \"inner_obj_field\":  234 } } }";
+      record = (GenericData.Record) convertJsonToAvro(jsonPayload, schema);
+      assertThat(record.get("f_union")).isInstanceOf(GenericData.Record.class);
+      innerRec = (GenericData.Record) record.get("f_union");
+      assertThat(innerRec.get("inner_obj_field")).isEqualTo(234);
+
+      assertThatThrownBy(() ->
+          convertJsonToAvro("{ \"f_union\": { \"TestAvroRecord\": { \"inner_obj_field\":  234 } } }", schema)
+      ).isInstanceOf(JsonAvroConversionException.class);
     }
     }
 
 
     @Test
     @Test
@@ -599,6 +651,46 @@ class JsonAvroConversionTest {
       var innerRec = new GenericData.Record(schema);
       var innerRec = new GenericData.Record(schema);
       innerRec.put("f_union", 123);
       innerRec.put("f_union", 123);
       r.put("f_union", innerRec);
       r.put("f_union", innerRec);
+      // short type name can be set since there is NO clash with other types name
+      assertJsonsEqual(
+          " { \"f_union\" : { \"TestAvroRecord\" : { \"f_union\" : { \"int\" : 123 } } } }",
+          convertAvroToJson(r, schema)
+      );
+    }
+
+    @Test
+    void unionFieldWithInnerTypesNamesClash() {
+      var schema = createSchema(
+          """
+               {
+                 "type": "record",
+                 "namespace": "com.test",
+                 "name": "TestAvroRecord",
+                 "fields": [
+                   {
+                     "name": "nestedClass",
+                     "type": {
+                       "type": "record",
+                       "namespace": "com.nested",
+                       "name": "TestAvroRecord",
+                       "fields": [
+                         {"name" : "inner_obj_field", "type": "int" }
+                       ]
+                     }
+                   },
+                   {
+                     "name": "f_union",
+                     "type": [ "null", "int", "com.test.TestAvroRecord", "com.nested.TestAvroRecord"]
+                   }
+                 ]
+              }"""
+      );
+
+      var r = new GenericData.Record(schema);
+      var innerRec = new GenericData.Record(schema);
+      innerRec.put("f_union", 123);
+      r.put("f_union", innerRec);
+      // full type name should be set since there is a clash with other type name
       assertJsonsEqual(
       assertJsonsEqual(
           " { \"f_union\" : { \"com.test.TestAvroRecord\" : { \"f_union\" : { \"int\" : 123 } } } }",
           " { \"f_union\" : { \"com.test.TestAvroRecord\" : { \"f_union\" : { \"int\" : 123 } } } }",
           convertAvroToJson(r, schema)
           convertAvroToJson(r, schema)

+ 66 - 0
kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml

@@ -685,6 +685,25 @@ paths:
               schema:
               schema:
                 $ref: '#/components/schemas/TopicSerdeSuggestion'
                 $ref: '#/components/schemas/TopicSerdeSuggestion'
 
 
+  /api/smartfilters/testexecutions:
+    put:
+      tags:
+        - Messages
+      summary: executeSmartFilterTest
+      operationId: executeSmartFilterTest
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/SmartFilterTestExecution'
+      responses:
+        200:
+          description: OK
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/SmartFilterTestExecutionResult'
+
 
 
   /api/clusters/{clusterName}/topics/{topicName}/messages:
   /api/clusters/{clusterName}/topics/{topicName}/messages:
     get:
     get:
@@ -2644,6 +2663,37 @@ components:
           items:
           items:
             $ref: '#/components/schemas/ConsumerGroup'
             $ref: '#/components/schemas/ConsumerGroup'
 
 
+    SmartFilterTestExecution:
+      type: object
+      required: [filterCode]
+      properties:
+        filterCode:
+          type: string
+        key:
+          type: string
+        value:
+          type: string
+        headers:
+          type: object
+          additionalProperties:
+            type: string
+        partition:
+          type: integer
+        offset:
+          type: integer
+          format: int64
+        timestampMs:
+          type: integer
+          format: int64
+
+    SmartFilterTestExecutionResult:
+      type: object
+      properties:
+        result:
+          type: boolean
+        error:
+          type: string
+
     CreateTopicMessage:
     CreateTopicMessage:
       type: object
       type: object
       properties:
       properties:
@@ -3525,6 +3575,7 @@ components:
         - CONNECT
         - CONNECT
         - KSQL
         - KSQL
         - ACL
         - ACL
+        - AUDIT
 
 
     KafkaAcl:
     KafkaAcl:
       type: object
       type: object
@@ -3885,3 +3936,18 @@ components:
                       pollingThrottleRate:
                       pollingThrottleRate:
                         type: integer
                         type: integer
                         format: int64
                         format: int64
+                      audit:
+                        type: object
+                        properties:
+                          topic:
+                            type: string
+                          auditTopicsPartitions:
+                            type: integer
+                          topicAuditEnabled:
+                            type: boolean
+                          consoleAuditEnabled:
+                            type: boolean
+                          auditTopicProperties:
+                            type: object
+                            additionalProperties:
+                              type: string

+ 3 - 3
kafka-ui-e2e-checks/pom.xml

@@ -19,12 +19,12 @@
         <selenium.version>4.8.1</selenium.version>
         <selenium.version>4.8.1</selenium.version>
         <selenide.version>6.12.3</selenide.version>
         <selenide.version>6.12.3</selenide.version>
         <testng.version>7.7.1</testng.version>
         <testng.version>7.7.1</testng.version>
-        <allure.version>2.21.0</allure.version>
+        <allure.version>2.22.2</allure.version>
         <qase.io.version>3.0.4</qase.io.version>
         <qase.io.version>3.0.4</qase.io.version>
         <aspectj.version>1.9.9.1</aspectj.version>
         <aspectj.version>1.9.9.1</aspectj.version>
         <assertj.version>3.24.2</assertj.version>
         <assertj.version>3.24.2</assertj.version>
         <hamcrest.version>2.2</hamcrest.version>
         <hamcrest.version>2.2</hamcrest.version>
-        <slf4j.version>2.0.5</slf4j.version>
+        <slf4j.version>2.0.7</slf4j.version>
         <kafka.version>3.3.1</kafka.version>
         <kafka.version>3.3.1</kafka.version>
     </properties>
     </properties>
 
 
@@ -267,7 +267,7 @@
                     <plugin>
                     <plugin>
                         <groupId>org.apache.maven.plugins</groupId>
                         <groupId>org.apache.maven.plugins</groupId>
                         <artifactId>maven-checkstyle-plugin</artifactId>
                         <artifactId>maven-checkstyle-plugin</artifactId>
-                        <version>3.1.2</version>
+                        <version>3.3.0</version>
                         <dependencies>
                         <dependencies>
                             <dependency>
                             <dependency>
                                 <groupId>com.puppycrawl.tools</groupId>
                                 <groupId>com.puppycrawl.tools</groupId>

+ 15 - 0
kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/utilities/StringUtils.java

@@ -0,0 +1,15 @@
+package com.provectus.kafka.ui.utilities;
+
+import java.util.stream.IntStream;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class StringUtils {
+
+  public static String getMixedCase(String original) {
+    return IntStream.range(0, original.length())
+        .mapToObj(i -> i % 2 == 0 ? Character.toUpperCase(original.charAt(i)) : original.charAt(i))
+        .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
+        .toString();
+  }
+}

+ 2 - 10
kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java

@@ -1,6 +1,5 @@
 package com.provectus.kafka.ui.manualsuite.backlog;
 package com.provectus.kafka.ui.manualsuite.backlog;
 
 
-import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.BROKERS_SUITE_ID;
 import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.SCHEMAS_SUITE_ID;
 import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.SCHEMAS_SUITE_ID;
 import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.TOPICS_PROFILE_SUITE_ID;
 import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.TOPICS_PROFILE_SUITE_ID;
 import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.TOPICS_SUITE_ID;
 import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.TOPICS_SUITE_ID;
@@ -57,24 +56,17 @@ public class SmokeBacklog extends BaseManualTest {
   public void testCaseF() {
   public void testCaseF() {
   }
   }
 
 
-  @Automation(state = TO_BE_AUTOMATED)
-  @Suite(id = BROKERS_SUITE_ID)
-  @QaseId(348)
-  @Test
-  public void testCaseG() {
-  }
-
   @Automation(state = NOT_AUTOMATED)
   @Automation(state = NOT_AUTOMATED)
   @Suite(id = TOPICS_SUITE_ID)
   @Suite(id = TOPICS_SUITE_ID)
   @QaseId(50)
   @QaseId(50)
   @Test
   @Test
-  public void testCaseH() {
+  public void testCaseG() {
   }
   }
 
 
   @Automation(state = NOT_AUTOMATED)
   @Automation(state = NOT_AUTOMATED)
   @Suite(id = SCHEMAS_SUITE_ID)
   @Suite(id = SCHEMAS_SUITE_ID)
   @QaseId(351)
   @QaseId(351)
   @Test
   @Test
-  public void testCaseI() {
+  public void testCaseH() {
   }
   }
 }
 }

+ 34 - 0
kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/brokers/BrokersTest.java

@@ -1,6 +1,7 @@
 package com.provectus.kafka.ui.smokesuite.brokers;
 package com.provectus.kafka.ui.smokesuite.brokers;
 
 
 import static com.provectus.kafka.ui.pages.brokers.BrokersDetails.DetailsTab.CONFIGS;
 import static com.provectus.kafka.ui.pages.brokers.BrokersDetails.DetailsTab.CONFIGS;
+import static com.provectus.kafka.ui.utilities.StringUtils.getMixedCase;
 import static com.provectus.kafka.ui.variables.Expected.BROKER_SOURCE_INFO_TOOLTIP;
 import static com.provectus.kafka.ui.variables.Expected.BROKER_SOURCE_INFO_TOOLTIP;
 
 
 import com.codeborne.selenide.Condition;
 import com.codeborne.selenide.Condition;
@@ -8,6 +9,7 @@ import com.provectus.kafka.ui.BaseTest;
 import com.provectus.kafka.ui.pages.brokers.BrokersConfigTab;
 import com.provectus.kafka.ui.pages.brokers.BrokersConfigTab;
 import io.qameta.allure.Issue;
 import io.qameta.allure.Issue;
 import io.qase.api.annotation.QaseId;
 import io.qase.api.annotation.QaseId;
+import java.util.List;
 import org.testng.Assert;
 import org.testng.Assert;
 import org.testng.annotations.Ignore;
 import org.testng.annotations.Ignore;
 import org.testng.annotations.Test;
 import org.testng.annotations.Test;
@@ -100,6 +102,38 @@ public class BrokersTest extends BaseTest {
         String.format("getAllConfigs().contains(%s)", anyConfigKeySecondPage));
         String.format("getAllConfigs().contains(%s)", anyConfigKeySecondPage));
   }
   }
 
 
+  @Ignore
+  @Issue("https://github.com/provectus/kafka-ui/issues/3347")
+  @QaseId(348)
+  @Test
+  public void brokersConfigCaseInsensitiveSearchCheck() {
+    navigateToBrokersAndOpenDetails(DEFAULT_BROKER_ID);
+    brokersDetails
+        .openDetailsTab(CONFIGS);
+    String anyConfigKeyFirstPage = brokersConfigTab
+        .getAllConfigs().stream()
+        .findAny().orElseThrow()
+        .getKey();
+    brokersConfigTab
+        .clickNextButton();
+    Assert.assertFalse(brokersConfigTab.getAllConfigs().stream()
+            .map(BrokersConfigTab.BrokersConfigItem::getKey)
+            .toList().contains(anyConfigKeyFirstPage),
+        String.format("getAllConfigs().contains(%s)", anyConfigKeyFirstPage));
+    SoftAssert softly = new SoftAssert();
+    List.of(anyConfigKeyFirstPage.toLowerCase(), anyConfigKeyFirstPage.toUpperCase(),
+            getMixedCase(anyConfigKeyFirstPage))
+        .forEach(configCase -> {
+          brokersConfigTab
+              .searchConfig(configCase);
+          softly.assertTrue(brokersConfigTab.getAllConfigs().stream()
+                  .map(BrokersConfigTab.BrokersConfigItem::getKey)
+                  .toList().contains(anyConfigKeyFirstPage),
+              String.format("getAllConfigs().contains(%s)", configCase));
+        });
+    softly.assertAll();
+  }
+
   @QaseId(331)
   @QaseId(331)
   @Test
   @Test
   public void brokersSourceInfoCheck() {
   public void brokersSourceInfoCheck() {

+ 1 - 1
kafka-ui-react-app/public/robots.txt

@@ -1,2 +1,2 @@
-# https://www.robotstxt.org/robotstxt.html
 User-agent: *
 User-agent: *
+Disallow: /

+ 4 - 1
kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx

@@ -44,7 +44,10 @@ const Broker: React.FC = () => {
       <Metrics.Wrapper>
       <Metrics.Wrapper>
         <Metrics.Section>
         <Metrics.Section>
           <Metrics.Indicator label="Segment Size">
           <Metrics.Indicator label="Segment Size">
-            <BytesFormatted value={brokerDiskUsage?.segmentSize} />
+            <BytesFormatted
+              value={brokerDiskUsage?.segmentSize}
+              precision={2}
+            />
           </Metrics.Indicator>
           </Metrics.Indicator>
           <Metrics.Indicator label="Segment Count">
           <Metrics.Indicator label="Segment Count">
             {brokerDiskUsage?.segmentCount}
             {brokerDiskUsage?.segmentCount}

+ 1 - 1
kafka-ui-react-app/src/components/Brokers/Broker/__test__/Broker.spec.tsx

@@ -66,7 +66,7 @@ describe('Broker Component', () => {
     expect(
     expect(
       screen.getByText(brokerDiskUsage?.segmentCount || '')
       screen.getByText(brokerDiskUsage?.segmentCount || '')
     ).toBeInTheDocument();
     ).toBeInTheDocument();
-    expect(screen.getByText('12 MB')).toBeInTheDocument();
+    expect(screen.getByText('11.77 MB')).toBeInTheDocument();
 
 
     expect(screen.getByText('Segment Count')).toBeInTheDocument();
     expect(screen.getByText('Segment Count')).toBeInTheDocument();
     expect(
     expect(

+ 1 - 0
kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx

@@ -105,6 +105,7 @@ const BrokersList: React.FC = () => {
               getValue={getValue}
               getValue={getValue}
               renderValue={renderValue}
               renderValue={renderValue}
               renderSegments
               renderSegments
+              precision={2}
             />
             />
           ),
           ),
       },
       },

+ 16 - 4
kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx

@@ -16,13 +16,15 @@ import { Table } from 'components/common/table/Table/Table.styled';
 import getTagColor from 'components/common/Tag/getTagColor';
 import getTagColor from 'components/common/Tag/getTagColor';
 import { Dropdown } from 'components/common/Dropdown';
 import { Dropdown } from 'components/common/Dropdown';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
-import { Action, ResourceType } from 'generated-sources';
+import { Action, ConsumerGroupState, ResourceType } from 'generated-sources';
 import { ActionDropdownItem } from 'components/common/ActionComponent';
 import { ActionDropdownItem } from 'components/common/ActionComponent';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import {
 import {
   useConsumerGroupDetails,
   useConsumerGroupDetails,
   useDeleteConsumerGroupMutation,
   useDeleteConsumerGroupMutation,
 } from 'lib/hooks/api/consumers';
 } from 'lib/hooks/api/consumers';
+import Tooltip from 'components/common/Tooltip/Tooltip';
+import { CONSUMER_GROUP_STATE_TOOLTIPS } from 'lib/constants';
 
 
 import ListItem from './ListItem';
 import ListItem from './ListItem';
 
 
@@ -96,9 +98,19 @@ const Details: React.FC = () => {
       <Metrics.Wrapper>
       <Metrics.Wrapper>
         <Metrics.Section>
         <Metrics.Section>
           <Metrics.Indicator label="State">
           <Metrics.Indicator label="State">
-            <Tag color={getTagColor(consumerGroup.data?.state)}>
-              {consumerGroup.data?.state}
-            </Tag>
+            <Tooltip
+              value={
+                <Tag color={getTagColor(consumerGroup.data?.state)}>
+                  {consumerGroup.data?.state}
+                </Tag>
+              }
+              content={
+                CONSUMER_GROUP_STATE_TOOLTIPS[
+                  consumerGroup.data?.state || ConsumerGroupState.UNKNOWN
+                ]
+              }
+              placement="bottom-start"
+            />
           </Metrics.Indicator>
           </Metrics.Indicator>
           <Metrics.Indicator label="Members">
           <Metrics.Indicator label="Members">
             {consumerGroup.data?.members}
             {consumerGroup.data?.members}

+ 18 - 3
kafka-ui-react-app/src/components/ConsumerGroups/List.tsx

@@ -5,15 +5,17 @@ import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel
 import {
 import {
   ConsumerGroupDetails,
   ConsumerGroupDetails,
   ConsumerGroupOrdering,
   ConsumerGroupOrdering,
+  ConsumerGroupState,
   SortOrder,
   SortOrder,
 } from 'generated-sources';
 } from 'generated-sources';
 import useAppParams from 'lib/hooks/useAppParams';
 import useAppParams from 'lib/hooks/useAppParams';
 import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
 import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
 import { ColumnDef } from '@tanstack/react-table';
 import { ColumnDef } from '@tanstack/react-table';
-import Table, { TagCell, LinkCell } from 'components/common/NewTable';
+import Table, { LinkCell, TagCell } from 'components/common/NewTable';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { PER_PAGE } from 'lib/constants';
+import { CONSUMER_GROUP_STATE_TOOLTIPS, PER_PAGE } from 'lib/constants';
 import { useConsumerGroups } from 'lib/hooks/api/consumers';
 import { useConsumerGroups } from 'lib/hooks/api/consumers';
+import Tooltip from 'components/common/Tooltip/Tooltip';
 
 
 const List = () => {
 const List = () => {
   const { clusterName } = useAppParams<ClusterNameRoute>();
   const { clusterName } = useAppParams<ClusterNameRoute>();
@@ -59,6 +61,9 @@ const List = () => {
         id: ConsumerGroupOrdering.MESSAGES_BEHIND,
         id: ConsumerGroupOrdering.MESSAGES_BEHIND,
         header: 'Consumer Lag',
         header: 'Consumer Lag',
         accessorKey: 'consumerLag',
         accessorKey: 'consumerLag',
+        cell: (args) => {
+          return args.getValue() || 'N/A';
+        },
       },
       },
       {
       {
         header: 'Coordinator',
         header: 'Coordinator',
@@ -69,7 +74,17 @@ const List = () => {
         id: ConsumerGroupOrdering.STATE,
         id: ConsumerGroupOrdering.STATE,
         header: 'State',
         header: 'State',
         accessorKey: 'state',
         accessorKey: 'state',
-        cell: TagCell,
+        // eslint-disable-next-line react/no-unstable-nested-components
+        cell: (args) => {
+          const value = args.getValue() as ConsumerGroupState;
+          return (
+            <Tooltip
+              value={<TagCell {...args} />}
+              content={CONSUMER_GROUP_STATE_TOOLTIPS[value]}
+              placement="bottom-end"
+            />
+          );
+        },
       },
       },
     ],
     ],
     []
     []

+ 3 - 3
kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx

@@ -6,10 +6,10 @@ import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 type AsAny = any;
 type AsAny = any;
 
 
 const SizeCell: React.FC<
 const SizeCell: React.FC<
-  CellContext<AsAny, unknown> & { renderSegments?: boolean }
-> = ({ getValue, row, renderSegments = false }) => (
+  CellContext<AsAny, unknown> & { renderSegments?: boolean; precision?: number }
+> = ({ getValue, row, renderSegments = false, precision = 0 }) => (
   <>
   <>
-    <BytesFormatted value={getValue<string | number>()} />
+    <BytesFormatted value={getValue<string | number>()} precision={precision} />
     {renderSegments ? `, ${row?.original.count} segment(s)` : null}
     {renderSegments ? `, ${row?.original.count} segment(s)` : null}
   </>
   </>
 );
 );

+ 12 - 1
kafka-ui-react-app/src/lib/constants.ts

@@ -1,5 +1,5 @@
 import { SelectOption } from 'components/common/Select/Select';
 import { SelectOption } from 'components/common/Select/Select';
-import { ConfigurationParameters } from 'generated-sources';
+import { ConfigurationParameters, ConsumerGroupState } from 'generated-sources';
 
 
 declare global {
 declare global {
   interface Window {
   interface Window {
@@ -96,3 +96,14 @@ export const METRICS_OPTIONS: SelectOption[] = [
   { value: 'JMX', label: 'JMX' },
   { value: 'JMX', label: 'JMX' },
   { value: 'PROMETHEUS', label: 'PROMETHEUS' },
   { value: 'PROMETHEUS', label: 'PROMETHEUS' },
 ];
 ];
+
+export const CONSUMER_GROUP_STATE_TOOLTIPS: Record<ConsumerGroupState, string> =
+  {
+    EMPTY: 'The group exists but has no members.',
+    STABLE: 'Consumers are happily consuming and have assigned partitions.',
+    PREPARING_REBALANCE:
+      'Something has changed, and the reassignment of partitions is required.',
+    COMPLETING_REBALANCE: 'Partition reassignment is in progress.',
+    DEAD: 'The group is going to be removed. It might be due to the inactivity, or the group is being migrated to different group coordinator.',
+    UNKNOWN: '',
+  } as const;

+ 5 - 0
kafka-ui-react-app/src/lib/hooks/api/topics.ts

@@ -304,6 +304,11 @@ export function useTopicAnalysis(
       useErrorBoundary: true,
       useErrorBoundary: true,
       retry: false,
       retry: false,
       suspense: false,
       suspense: false,
+      onError: (error: Response) => {
+        if (error.status !== 404) {
+          showServerError(error as Response);
+        }
+      },
     }
     }
   );
   );
 }
 }

+ 2 - 2
pom.xml

@@ -31,9 +31,9 @@
         <groovy.version>3.0.13</groovy.version>
         <groovy.version>3.0.13</groovy.version>
         <jackson.version>2.14.0</jackson.version>
         <jackson.version>2.14.0</jackson.version>
         <kafka-clients.version>3.3.1</kafka-clients.version>
         <kafka-clients.version>3.3.1</kafka-clients.version>
-        <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
+        <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
         <org.projectlombok.version>1.18.24</org.projectlombok.version>
         <org.projectlombok.version>1.18.24</org.projectlombok.version>
-        <protobuf-java.version>3.21.9</protobuf-java.version>
+        <protobuf-java.version>3.23.3</protobuf-java.version>
         <scala-lang.library.version>2.13.9</scala-lang.library.version>
         <scala-lang.library.version>2.13.9</scala-lang.library.version>
         <snakeyaml.version>2.0</snakeyaml.version>
         <snakeyaml.version>2.0</snakeyaml.version>
         <spring-boot.version>3.0.6</spring-boot.version>
         <spring-boot.version>3.0.6</spring-boot.version>