diff --git a/.env b/.env
new file mode 100644
index 0000000..3309f31
--- /dev/null
+++ b/.env
@@ -0,0 +1,31 @@
+CART_SERVICE_PORT=8100
+CART_SERVICE_DB_PORT=9100
+
+PAYMENT_SERVICE_PORT=8200
+PAYMENT_SERVICE_DB_PORT=9200
+
+ORDER_SERVICE_PORT=8300
+ORDER_SERVICE_DB_PORT=9300
+
+PRODUCT_SERVICE_PORT=8400
+PRODUCT_SERVICE_DB_PORT=9400
+
+USER_SERVICE_PORT=8500
+USER_SERVICE_DB_PORT=9500
+
+GATEWAY_SERVICE_PORT=8600
+
+#######################################
+
+# intentionally left blank (it is automatically set to 'compose' profile)
+PAYMENT_SERVICE_SPRING_PROFILES_ACTIVE=
+ORDER_SERVICE_SPRING_PROFILES_ACTIVE=
+PRODUCT_SERVICE_SPRING_PROFILES_ACTIVE=
+CART_SERVICE_SPRING_PROFILES_ACTIVE=
+USER_SERVICE_SPRING_PROFILES_ACTIVE=
+GATEWAY_SERVICE_SPRING_PROFILES_ACTIVE=
+
+#######################################
+
+COMPOSE_PROFILES=payment-service,order-service,cart-service,product-service,gateway-service,kafka
+
diff --git a/.github/workflows/build-java-push-gcp-registry.yaml b/.github/workflows/build-java-push-gcp-registry.yaml
new file mode 100644
index 0000000..2447053
--- /dev/null
+++ b/.github/workflows/build-java-push-gcp-registry.yaml
@@ -0,0 +1,68 @@
+name: Build and deploy Docker images to GCP Artifact Registry
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+env:
+ GCP_REGISTRY: ${{ secrets.ECMSP_GCP_ARTIFACT_REGISTRY }}
+ IMAGE_TAG: ${{ github.sha }}
+ IMAGE_NAME: ${{ github.event.repository.name }}
+
+jobs:
+ build_deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ -
+ name: Check out code
+ uses: actions/checkout@v3
+
+ -
+ name: Set up JDK 21
+ uses: actions/setup-java@v3
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ -
+ name: Update Maven snapshots
+ run: mvn -U dependency:resolve
+
+ -
+ name: Authenticate to Google Cloud
+ uses: google-github-actions/auth@v1
+ with:
+ credentials_json: ${{ secrets.ECMSP_REGISTRY_PUSHER_GCP_SA_KEY }}
+
+ -
+ name: Setup gcloud CLI
+ uses: google-github-actions/setup-gcloud@v2
+ with:
+ version: "latest"
+ project_id: ecmsp
+
+ -
+ name: Install gcloud auth
+ run: gcloud components install gke-gcloud-auth-plugin
+
+ -
+ name: Configure Docker for Artifact Registry
+ run: gcloud auth configure-docker europe-west1-docker.pkg.dev
+
+ -
+ name: Build, tag, and push backend Docker image to GCP Artifact Registry
+ run: |
+ docker build \
+ -t $GCP_REGISTRY/$IMAGE_NAME:$IMAGE_TAG \
+ -t $GCP_REGISTRY/$IMAGE_NAME:latest \
+ -f ./Dockerfile .
+
+ docker push $GCP_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
+ docker push $GCP_REGISTRY/$IMAGE_NAME:latest
+
+
+
diff --git a/.github/workflows/build-test-java-project.yaml b/.github/workflows/build-test-java-project.yaml
new file mode 100644
index 0000000..406cfdf
--- /dev/null
+++ b/.github/workflows/build-test-java-project.yaml
@@ -0,0 +1,45 @@
+name: Build and test Java project
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ build_test:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout repository
+ uses: actions/checkout@v2
+
+ -
+ name: Set up JDK 21
+ uses: actions/setup-java@v3
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ -
+ name: Run tests with Maven
+ run: mvn clean test -U
+
+# -
+# name: Build Docker image for Trivy
+# if: github.event_name == 'push' && (github.event.pull_request.base.ref == 'test' || github.event.pull_request.base.ref == 'development')
+# run: |
+# docker build \
+# -t service:latest \
+# -f ./Dockerfile .
+#
+# -
+# name: Run Trivy vulnerability scanner
+# if: github.event_name == 'push' && (github.event.pull_request.base.ref == 'test' || github.event.pull_request.base.ref == 'development')
+# uses: aquasecurity/trivy-action@0.28.0
+# with:
+# image-ref: "service:latest"
+# format: table
+# exit-code: 1
+# ignore-unfixed: true
+# vuln-type: os,library
+# severity: CRITICAL,HIGH
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 667aaef..2bcbfd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,7 @@ target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
-
+**/src/main/resources/local/secrets
### STS ###
.apt_generated
.classpath
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..580b49f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM maven:3.9-eclipse-temurin-21 AS builder
+WORKDIR /app
+COPY pom.xml .
+COPY src ./src
+RUN mvn clean package
+
+FROM eclipse-temurin:21-jdk AS runtime
+WORKDIR /app
+
+COPY --from=builder /app/target/*.jar ./app.jar
+CMD ["java", "-jar", "app.jar"]
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..2f83061
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,308 @@
+name: ecmsp-full
+
+services:
+ gateway-service:
+ build:
+ context: ../gateway-service/
+ dockerfile: Dockerfile
+ container_name: gateway-service
+ ports:
+ - "${GATEWAY_SERVICE_PORT:?error}:8080"
+ env_file:
+ -
+ path: ../gateway-service/docker/app-env.txt
+ required: false
+ environment:
+ - SPRING_PROFILES_ACTIVE=${GATEWAY_SERVICE_SPRING_PROFILES_ACTIVE:-compose}
+ profiles: [gateway-service]
+
+ user-service:
+ build:
+ context: ../user-service/
+ dockerfile: Dockerfile
+ container_name: user-service
+ ports:
+ - "${USER_SERVICE_PORT:?error}:8080"
+ env_file:
+ -
+ path: ../user-service/docker/app-env.txt
+ required: false
+ environment:
+ - SPRING_PROFILES_ACTIVE=${USER_SERVICE_SPRING_PROFILES_ACTIVE:-compose}
+ profiles: [user-service]
+ user-service-db:
+ image: postgres:15-alpine
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ ports:
+ - "${USER_SERVICE_DB_PORT:?error}:5432"
+ volumes:
+ - ../user-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql
+ - user-service-db:/var/lib/postgresql/data
+ secrets:
+ - user-service-db-password
+ environment:
+ - POSTGRES_USER=admin
+ - POSTGRES_DB=user-service-db
+ - POSTGRES_PASSWORD_FILE=/run/secrets/user-service-db
+ env_file:
+ -
+ path: ../user-service-db/docker/database-env.txt
+ required: false
+ profiles: [user-service]
+
+ product-service:
+ build:
+ context: ../product-service
+ dockerfile: Dockerfile
+ container_name: product-service
+ ports:
+ - "${PRODUCT_SERVICE_PORT:?error}:8080"
+ env_file:
+ -
+ path: ../product-service/docker/app-env.txt
+ required: false
+ environment:
+ - SPRING_PROFILES_ACTIVE=${PRODUCT_SERVICE_SPRING_PROFILES_ACTIVE:-compose}
+ depends_on:
+ product-service-db:
+ restart: true
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ profiles: [product-service]
+ #
+ product-service-db:
+ image: postgres:15-alpine
+ container_name: product-service-db
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ ports:
+ - "${PRODUCT_SERVICE_DB_PORT:?error}:5432"
+ volumes:
+ - ../product-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql
+ - product-service-db:/var/lib/postgresql/data
+ secrets:
+ - product-service-db-password
+ environment:
+ - POSTGRES_USER=admin
+ - POSTGRES_DB=product-service-db
+ - POSTGRES_PASSWORD_FILE=/run/secrets/product-service-db-password
+ env_file:
+ -
+ path: ../product-service/docker/database-env.txt
+ required: false
+ profiles: [product-service]
+ #
+
+ order-service:
+ build:
+ context: ../order-service
+ dockerfile: Dockerfile
+ container_name: order-service
+ ports:
+ - "${ORDER_SERVICE_PORT:?error}:8080"
+ env_file:
+ -
+ path: ../order-service/docker/app-env.txt
+ required: false
+ environment:
+ - SPRING_PROFILES_ACTIVE=${ORDER_SERVICE_SPRING_PROFILES_ACTIVE:-compose}
+ depends_on:
+ order-service-db:
+ restart: true
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ profiles: [order-service]
+ #
+ order-service-db:
+ image: postgres:15-alpine
+ container_name: order-service-db
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ ports:
+ - "${ORDER_SERVICE_DB_PORT:?error}:5432"
+ volumes:
+ - ../order-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql
+ - order-service-db:/var/lib/postgresql/data
+ secrets:
+ - order-service-db-password
+ environment:
+ - POSTGRES_USER=admin
+ - POSTGRES_DB=order-service-db
+ - POSTGRES_PASSWORD_FILE=/run/secrets/order-service-db-password
+ env_file:
+ -
+ path: ../order-service/docker/database-env.txt
+ required: false
+ profiles: [order-service]
+ #
+
+ payment-service:
+ build:
+ context: ../payment-service
+ dockerfile: Dockerfile
+ container_name: payment-service
+ ports:
+ - "${PAYMENT_SERVICE_PORT:?error}:8080"
+ env_file:
+ -
+ path: ../payment-service/docker/app-env.txt
+ required: false
+ environment:
+ - SPRING_PROFILES_ACTIVE=${PAYMENT_SERVICE_SPRING_PROFILES_ACTIVE:-compose}
+ depends_on:
+ payment-service-db:
+ restart: true
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ profiles: [payment-service]
+ #
+ payment-service-db:
+ image: postgres:15-alpine
+ container_name: payment-service-db
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ ports:
+ - "${PAYMENT_SERVICE_DB_PORT:?error}:5432"
+ volumes:
+ - ../payment-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql
+ - payment-service-db:/var/lib/postgresql/data
+ secrets:
+ - payment-service-db-password
+ environment:
+ - POSTGRES_USER=admin
+ - POSTGRES_DB=payment-service-db
+ - POSTGRES_PASSWORD_FILE=/run/secrets/payment-service-db-password
+ env_file:
+ -
+ path: ../payment-service/docker/database-env.txt
+ required: false
+ profiles: [payment-service]
+ #
+
+ cart-service:
+ build:
+ context: ../cart-service
+ dockerfile: Dockerfile
+ container_name: cart-service
+ ports:
+ - "${CART_SERVICE_PORT:?error}:8080"
+ env_file:
+ -
+ path: ../cart-service/docker/app-env.txt
+ required: false
+ environment:
+ - SPRING_PROFILES_ACTIVE=${CART_SERVICE_SPRING_PROFILES_ACTIVE:-compose}
+ depends_on:
+ cart-service-db:
+ restart: true
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ profiles: [cart-service]
+ #
+ cart-service-db:
+ image: postgres:15-alpine
+ container_name: cart-service-db
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ ports:
+ - "${CART_SERVICE_DB_PORT:?error}:5432"
+ volumes:
+ - ../cart-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql
+ - cart-service-db:/var/lib/postgresql/data
+ secrets:
+ - cart-service-db-password
+ environment:
+ - POSTGRES_USER=admin
+ - POSTGRES_DB=cart-service-db
+ - POSTGRES_PASSWORD_FILE=/run/secrets/cart-service-db-password
+ env_file:
+ -
+ path: ../cart-service/docker/database-env.txt
+ required: false
+ profiles: [cart-service]
+ #
+
+ kafka:
+ image: apache/kafka:3.7.0
+ container_name: broker
+ environment:
+ KAFKA_NODE_ID: 1
+ KAFKA_PROCESS_ROLES: broker,controller
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:9092,EXTERNAL://localhost:9094
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:9093
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ ports:
+ - "9094:9094"
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "./opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1",
+ ]
+ interval: 10s
+ timeout: 10s
+ retries: 5
+ profiles: [kafka]
+
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: kafka-ui
+ ports:
+ - "8088:8080"
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: broker:9092
+ KAFKA_CLUSTERS_0_READONLY: "false"
+ # KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry:8081
+ depends_on:
+ kafka:
+ condition: service_healthy
+ profiles: [kafka]
+
+secrets:
+ product-service-db-password:
+ file: ../product-service/docker/database-password.txt
+ order-service-db-password:
+ file: ../order-service/docker/database-password.txt
+ payment-service-db-password:
+ file: ../payment-service/docker/database-password.txt
+ cart-service-db-password:
+ file: ../cart-service/docker/database-password.txt
+ user-service-db-password:
+ file: ../user-service/docker/database-password.txt
+volumes:
+ product-service-db:
+ order-service-db:
+ payment-service-db:
+ cart-service-db:
+ user-service-db:
diff --git a/docker/app-env.txt b/docker/app-env.txt
new file mode 100644
index 0000000..d1158d6
--- /dev/null
+++ b/docker/app-env.txt
@@ -0,0 +1,2 @@
+SPRING_DATASOURCE_USERNAME=admin
+SPRING_DATASOURCE_PASSWORD=admin
\ No newline at end of file
diff --git a/docker/database-env.txt b/docker/database-env.txt
new file mode 100644
index 0000000..801e32f
--- /dev/null
+++ b/docker/database-env.txt
@@ -0,0 +1,2 @@
+POSTGRES_DB=user-service-db
+POSTGRES_USER=admin
\ No newline at end of file
diff --git a/docker/database-password.txt b/docker/database-password.txt
new file mode 100644
index 0000000..f77b004
--- /dev/null
+++ b/docker/database-password.txt
@@ -0,0 +1 @@
+admin
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 0000000..4b9cd64
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,75 @@
+name: ecmsp-dev
+
+services:
+ user-service-db-dev:
+ image: postgres:15-alpine
+ container_name: user-service-db-dev
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+
+ ports:
+ - "5432:5432"
+ volumes:
+ - user-service-db-dev:/var/lib/postgresql/data
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql
+ secrets:
+ - user-service-db-password
+ environment:
+ - POSTGRES_USER=admin
+ - POSTGRES_DB=user-service-db
+ - POSTGRES_PASSWORD_FILE=/run/secrets/user-service-db-password
+ env_file:
+ -
+ path: ./database-env.txt
+ required: false
+
+ kafka:
+ image: apache/kafka:3.7.0
+ container_name: broker
+ environment:
+ KAFKA_NODE_ID: 1
+ KAFKA_PROCESS_ROLES: broker,controller
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:9092,EXTERNAL://localhost:9094
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:9093
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ ports:
+ - "9094:9094"
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "./opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1",
+ ]
+ interval: 10s
+ timeout: 10s
+ retries: 5
+
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: kafka-ui
+ ports:
+ - "8088:8080"
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: broker:9092
+ KAFKA_CLUSTERS_0_READONLY: "false"
+ # KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry:8081
+ depends_on:
+ kafka:
+ condition: service_healthy
+
+volumes:
+ user-service-db-dev:
+
+secrets:
+ user-service-db-password:
+ file: ./database-password.txt
\ No newline at end of file
diff --git a/docker/init.sql b/docker/init.sql
new file mode 100644
index 0000000..e69de29
diff --git a/mvnw b/mvnw
old mode 100644
new mode 100755
diff --git a/pom.xml b/pom.xml
index f0dcaaf..9aba7d2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,6 +29,7 @@
21
2025.0.0
+ 1.63.0
@@ -39,10 +40,42 @@
org.springframework.boot
spring-boot-starter-validation
+
+ com.h2database
+ h2
+ test
+
+
+ net.devh
+ grpc-spring-boot-starter
+ 3.1.0.RELEASE
+
+
+ org.mindrot
+ jbcrypt
+ 0.4
+
org.springframework.boot
spring-boot-starter-web
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
org.springframework.boot
@@ -66,11 +99,52 @@
test
- org.springframework.cloud
- spring-cloud-starter-contract-verifier
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ com.h2database
+ h2
test
+
+ org.springframework.kafka
+ spring-kafka
+ 3.3.4
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ 3.5.3
+
+
+ org.testcontainers
+ testcontainers
+ 1.21.3
+
+
+ org.testcontainers
+ postgresql
+ 1.21.3
+
+
+ org.flywaydb
+ flyway-core
+ 11.11.2
+
+
+ org.flywaydb
+ flyway-database-postgresql
+ 11.11.2
+ runtime
+
+
+ com.ecmsp
+ protos
+ 1.0.0-20251105.185705-41
+
+
@@ -80,6 +154,13 @@
pom
import
+
+ io.grpc
+ grpc-bom
+ ${grpc.version}
+ pom
+ import
+
@@ -97,15 +178,6 @@
-
- org.springframework.cloud
- spring-cloud-contract-maven-plugin
- 4.3.0
- true
-
- JUNIT5
-
-
org.springframework.boot
spring-boot-maven-plugin
@@ -121,4 +193,21 @@
+
+
+ central
+ Maven Central
+ https://repo.maven.apache.org/maven2
+
+
+ nexus-ecmsp-releases
+ nexus-ecmsp-releases
+ https://nexus.ecmsp.pl/repository/maven-releases/
+
+
+ nexus-ecmsp-snapshots
+ nexus-ecmsp-snapshots
+ https://nexus.ecmsp.pl/repository/maven-snapshots/
+
+
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..9d3c4ec
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,38 @@
+# User Service
+
+## Installation
+
+**Generate RSA keys:**
+ ```bash
+ cd src/main/resources/local/secrets/
+ openssl genrsa > local.private.key
+ openssl rsa -in local.private.key -pubout > local.public.key
+ ```
+**For macOS (maybe windows) users run 1 more command:**
+ ```bash
+ openssl pkcs8 -topk8 -inform PEM -outform PEM -in local.private.key -out local.private.pkcs8.key -nocrypt
+ ```
+then change `secret-key-file` in application.yml to ```secret-key-file: classpath:local/secrets/local.private.pkcs8.key```
+## User Controller API
+
+### Create User
+```
+POST http://localhost:8500/api/users
+Content-Type: application/json
+
+{
+ "login": "testuser",
+ "password": "testpassword"
+}
+```
+
+### Authenticate User
+```
+POST http://localhost:8500/api/auth/authenticate
+Content-Type: application/json
+
+{
+ "login": "testuser",
+ "password": "testpassword"
+}
+```
\ No newline at end of file
diff --git a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java
index 88b1482..759bfbb 100644
--- a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java
+++ b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java
@@ -2,9 +2,16 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
-@SpringBootApplication
-public class UserServiceApplication {
+@SpringBootApplication(
+ exclude = {
+ DataSourceAutoConfiguration.class,
+ HibernateJpaAutoConfiguration.class,
+ }
+)
+public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java
new file mode 100644
index 0000000..202d055
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java
@@ -0,0 +1,62 @@
+package com.ecmsp.userservice.api.grpc;
+
+import com.ecmsp.user.v1.RoleId;
+import com.ecmsp.user.v1.UserId;
+import com.ecmsp.userservice.user.domain.Permission;
+import com.ecmsp.userservice.user.domain.Role;
+import com.ecmsp.userservice.user.domain.UserView;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Component
+public class UserGrpcMapper {
+
+ public com.ecmsp.user.v1.User toProtoUser(UserView userView) {
+ return com.ecmsp.user.v1.User.newBuilder()
+ .setId(toProtoUserId(userView.id()))
+ .setLogin(userView.login())
+ .addAllRoles(userView.roles().stream()
+ .map(this::toProtoRole)
+ .collect(Collectors.toList()))
+ .build();
+ }
+
+ public UserId toProtoUserId(com.ecmsp.userservice.user.domain.UserId domainUserId) {
+ return UserId.newBuilder()
+ .setValue(domainUserId.value().toString())
+ .build();
+ }
+
+ public com.ecmsp.user.v1.Role toProtoRole(Role domainRole) {
+ return com.ecmsp.user.v1.Role.newBuilder()
+ .setId(toProtoRoleId(domainRole.id()))
+ .setName(domainRole.name())
+ .addAllPermissions(domainRole.permissions().stream()
+ .map(this::toProtoPermission)
+ .collect(Collectors.toList()))
+ .build();
+ }
+
+ public RoleId toProtoRoleId(com.ecmsp.userservice.user.domain.RoleId domainRoleId) {
+ return RoleId.newBuilder()
+ .setValue(domainRoleId.value().toString())
+ .build();
+ }
+
+ public String toProtoPermission(Permission permission) {
+ return permission.name();
+ }
+
+ public com.ecmsp.userservice.user.domain.UserId toDomainUserId(UserId protoUserId) {
+ return new com.ecmsp.userservice.user.domain.UserId(UUID.fromString(protoUserId.getValue()));
+ }
+
+ public com.ecmsp.userservice.user.domain.UserToCreate toDomainUserToCreate(com.ecmsp.user.v1.UserToCreate protoUserToCreate) {
+ return new com.ecmsp.userservice.user.domain.UserToCreate(
+ protoUserToCreate.getLogin(),
+ protoUserToCreate.getPassword()
+ );
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java
new file mode 100644
index 0000000..a0dc821
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java
@@ -0,0 +1,256 @@
+package com.ecmsp.userservice.api.grpc;
+
+import com.ecmsp.user.v1.*;
+import com.ecmsp.userservice.user.domain.User;
+import com.ecmsp.userservice.user.domain.UserFacade;
+import com.ecmsp.userservice.user.domain.UserId;
+import com.ecmsp.userservice.user.domain.UserView;
+import io.grpc.Status;
+import io.grpc.stub.StreamObserver;
+import net.devh.boot.grpc.server.service.GrpcService;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@GrpcService
+public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
+ private final UserFacade userFacade;
+ private final UserGrpcMapper mapper;
+
+ public UserGrpcService(UserFacade userFacade, UserGrpcMapper mapper) {
+ this.userFacade = userFacade;
+ this.mapper = mapper;
+ }
+
+ @Override
+ public void getUser(GetUserRequest request, StreamObserver responseObserver) {
+ try {
+ com.ecmsp.user.v1.UserId protoUserId = com.ecmsp.user.v1.UserId.newBuilder()
+ .setValue(request.getUserId())
+ .build();
+ UserId userId = mapper.toDomainUserId(protoUserId);
+ Optional userOptional = userFacade.findUserById(userId);
+
+ if (userOptional.isPresent()) {
+ User user = userOptional.get();
+ UserView userView = new UserView(user.id(), user.login(), user.roles());
+ com.ecmsp.user.v1.User protoUser = mapper.toProtoUser(userView);
+
+ GetUserResponse response = GetUserResponse.newBuilder()
+ .setUser(protoUser)
+ .build();
+
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
+ } else {
+ responseObserver.onError(Status.NOT_FOUND
+ .withDescription("User not found with id: " + request.getUserId())
+ .asRuntimeException());
+ }
+ } catch (IllegalArgumentException e) {
+ responseObserver.onError(Status.INVALID_ARGUMENT
+ .withDescription(e.getMessage())
+ .asRuntimeException());
+ } catch (Exception e) {
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("Internal error: " + e.getMessage())
+ .asRuntimeException());
+ }
+ }
+
+ @Override
+ public void createUser(CreateUserRequest request, StreamObserver responseObserver) {
+ try {
+ com.ecmsp.user.v1.UserToCreate protoUserToCreate = request.getUserToCreate();
+
+ // Validate input
+ if (protoUserToCreate.getLogin() == null || protoUserToCreate.getLogin().isBlank()) {
+ responseObserver.onError(Status.INVALID_ARGUMENT
+ .withDescription("Login cannot be empty")
+ .asRuntimeException());
+ return;
+ }
+
+ if (protoUserToCreate.getPassword() == null || protoUserToCreate.getPassword().isBlank()) {
+ responseObserver.onError(Status.INVALID_ARGUMENT
+ .withDescription("Password cannot be empty")
+ .asRuntimeException());
+ return;
+ }
+
+ com.ecmsp.userservice.user.domain.UserToCreate domainUserToCreate = mapper.toDomainUserToCreate(protoUserToCreate);
+ User createdUser = userFacade.createUser(domainUserToCreate);
+
+ UserView userView = new UserView(createdUser.id(), createdUser.login(), createdUser.roles());
+ com.ecmsp.user.v1.User protoUser = mapper.toProtoUser(userView);
+
+ CreateUserResponse response = CreateUserResponse.newBuilder()
+ .setUser(protoUser)
+ .build();
+
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
+ } catch (RuntimeException e) {
+ if (e.getMessage() != null && e.getMessage().contains("already exists")) {
+ responseObserver.onError(Status.ALREADY_EXISTS
+ .withDescription("User with this login already exists")
+ .asRuntimeException());
+ } else {
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("Internal error: " + e.getMessage())
+ .asRuntimeException());
+ }
+ } catch (Exception e) {
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("Internal error: " + e.getMessage())
+ .asRuntimeException());
+ }
+ }
+
+ @Override
+ public void updateUser(UpdateUserRequest request, StreamObserver responseObserver) {
+ try {
+ UserId userId = mapper.toDomainUserId(request.getUser().getId());
+ String newLogin = request.getUser().getLogin();
+
+ if (newLogin == null || newLogin.isBlank()) {
+ responseObserver.onError(Status.INVALID_ARGUMENT
+ .withDescription("Login cannot be empty")
+ .asRuntimeException());
+ return;
+ }
+
+ userFacade.updateUserLogin(userId, newLogin);
+
+ // Fetch updated user
+ Optional updatedUserOptional = userFacade.findUserById(userId);
+ if (updatedUserOptional.isPresent()) {
+ User updatedUser = updatedUserOptional.get();
+ UserView userView = new UserView(updatedUser.id(), updatedUser.login(), updatedUser.roles());
+ com.ecmsp.user.v1.User protoUser = mapper.toProtoUser(userView);
+
+ UpdateUserResponse response = UpdateUserResponse.newBuilder()
+ .setUser(protoUser)
+ .build();
+
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
+ } else {
+ responseObserver.onError(Status.NOT_FOUND
+ .withDescription("User not found after update")
+ .asRuntimeException());
+ }
+ } catch (IllegalArgumentException e) {
+ responseObserver.onError(Status.NOT_FOUND
+ .withDescription(e.getMessage())
+ .asRuntimeException());
+ } catch (Exception e) {
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("Internal error: " + e.getMessage())
+ .asRuntimeException());
+ }
+ }
+
+ @Override
+ public void deleteUser(DeleteUserRequest request, StreamObserver responseObserver) {
+ try {
+ com.ecmsp.user.v1.UserId protoUserId = com.ecmsp.user.v1.UserId.newBuilder()
+ .setValue(request.getUserId())
+ .build();
+ UserId userId = mapper.toDomainUserId(protoUserId);
+ userFacade.deleteUser(userId);
+
+ DeleteUserResponse response = DeleteUserResponse.newBuilder().build();
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
+ } catch (IllegalArgumentException e) {
+ responseObserver.onError(Status.NOT_FOUND
+ .withDescription(e.getMessage())
+ .asRuntimeException());
+ } catch (Exception e) {
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("Internal error: " + e.getMessage())
+ .asRuntimeException());
+ }
+ }
+
+ @Override
+ public void listUsers(ListUsersRequest request, StreamObserver responseObserver) {
+ try {
+ String filterLogin = request.getFilterLogin();
+ List users = userFacade.listUsers(filterLogin);
+
+ List protoUsers = users.stream()
+ .map(mapper::toProtoUser)
+ .collect(Collectors.toList());
+
+ ListUsersResponse response = ListUsersResponse.newBuilder()
+ .addAllUsers(protoUsers)
+ .build();
+
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
+ } catch (Exception e) {
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("Internal error: " + e.getMessage())
+ .asRuntimeException());
+ }
+ }
+
+ @Override
+ public void createRole(CreateRoleRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+
+ @Override
+ public void updateRole(UpdateRoleRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+
+ @Override
+ public void deleteRole(DeleteRoleRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+
+ @Override
+ public void listRoles(ListRolesRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+
+ @Override
+ public void assignRoleToUsers(AssignRoleToUsersRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+
+ @Override
+ public void removeRoleFromUsers(RemoveRoleFromUsersRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+
+ @Override
+ public void listAllPermissions(ListAllPermissionsRequest request, StreamObserver responseObserver) {
+ // TODO: Implement role management later
+ responseObserver.onError(Status.UNIMPLEMENTED
+ .withDescription("Role management not yet implemented")
+ .asRuntimeException());
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java
new file mode 100644
index 0000000..9ad440a
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java
@@ -0,0 +1,5 @@
+package com.ecmsp.userservice.api.grpc.context;
+
+public record UserContextData(String userId,
+ String login) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java
new file mode 100644
index 0000000..5819e47
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java
@@ -0,0 +1,12 @@
+package com.ecmsp.userservice.api.grpc.context;
+
+import io.grpc.Context;
+
+public class UserContextGrpcHolder {
+
+ public static final Context.Key USER_CONTEXT_KEY = Context.key("user-context");
+
+ public static UserContextData getUserContext() {
+ return USER_CONTEXT_KEY.get();
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java
new file mode 100644
index 0000000..d37de61
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java
@@ -0,0 +1,25 @@
+package com.ecmsp.userservice.api.grpc.context;
+
+import io.grpc.*;
+import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor;
+
+@GrpcGlobalServerInterceptor
+public class UserContextGrpcInterceptor implements ServerInterceptor {
+
+ @Override
+ public ServerCall.Listener interceptCall(
+ ServerCall call,
+ Metadata headers,
+ ServerCallHandler next) {
+
+ UserContextData userContextData = new UserContextData(
+ headers.get(Metadata.Key.of("X-User-ID", Metadata.ASCII_STRING_MARSHALLER)),
+ headers.get(Metadata.Key.of("X-Login", Metadata.ASCII_STRING_MARSHALLER))
+ );
+
+ Context context = Context.current()
+ .withValue(UserContextGrpcHolder.USER_CONTEXT_KEY, userContextData);
+
+ return Contexts.interceptCall(context, call, headers, next);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/UserController.java b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java
new file mode 100644
index 0000000..10297d3
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java
@@ -0,0 +1,67 @@
+package com.ecmsp.userservice.api.rest;
+
+
+import com.ecmsp.userservice.api.rest.role.AssignRoleRequest;
+import com.ecmsp.userservice.api.rest.role.RoleResponse;
+import com.ecmsp.userservice.user.domain.Role;
+import com.ecmsp.userservice.user.domain.RoleId;
+import com.ecmsp.userservice.user.domain.UserFacade;
+import com.ecmsp.userservice.user.domain.UserId;
+import com.ecmsp.userservice.user.domain.UserToCreate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/users")
+public class UserController {
+
+ private final UserFacade userFacade;
+
+ public UserController(UserFacade userFacade) {
+ this.userFacade = userFacade;
+ }
+
+ @PostMapping
+ public ResponseEntity createUser(@RequestBody UserCreateRequest request) {
+ UserToCreate user = new UserToCreate(request.login(), request.password());
+ userFacade.createUser(user);
+ return ResponseEntity.status(HttpStatus.CREATED).build();
+ }
+
+ @PostMapping("/{userId}/roles")
+ public ResponseEntity assignRoleToUser(
+ @PathVariable UUID userId,
+ @RequestBody AssignRoleRequest request) {
+ userFacade.assignRoleToUser(new UserId(userId), new RoleId(request.roleId()));
+ return ResponseEntity.ok().build();
+ }
+
+ @DeleteMapping("/{userId}/roles/{roleId}")
+ public ResponseEntity removeRoleFromUser(
+ @PathVariable UUID userId,
+ @PathVariable UUID roleId) {
+ userFacade.removeRoleFromUser(new UserId(userId), new RoleId(roleId));
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/{userId}/roles")
+ public ResponseEntity> getUserRoles(@PathVariable UUID userId) {
+ Set roles = userFacade.getUserRoles(new UserId(userId));
+
+ List roleResponses = roles.stream()
+ .map(role -> new RoleResponse(
+ role.id().value(),
+ role.name(),
+ role.permissions()
+ ))
+ .collect(Collectors.toList());
+
+ return ResponseEntity.ok(roleResponses);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java
new file mode 100644
index 0000000..b097445
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java
@@ -0,0 +1,4 @@
+package com.ecmsp.userservice.api.rest;
+
+public record UserCreateRequest(String login, String password) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java
new file mode 100644
index 0000000..b2248cf
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java
@@ -0,0 +1,30 @@
+package com.ecmsp.userservice.api.rest.auth;
+
+import com.ecmsp.userservice.auth.domain.AuthFacade;
+import com.ecmsp.userservice.auth.domain.AuthenticationResult;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController {
+
+ private final AuthFacade authFacade;
+
+ public AuthController(AuthFacade authFacade) {
+ this.authFacade = authFacade;
+ }
+
+ @PostMapping("/authenticate")
+ public ResponseEntity authenticate(@RequestBody AuthRequest request) {
+ AuthenticationResult result = authFacade.authenticate(request.login(), request.password());
+ return switch (result) {
+ case AuthenticationResult.Success success -> ResponseEntity.ok(new AuthResponse(success.token().value()));
+ case AuthenticationResult.Failure ignored -> ResponseEntity.badRequest().build();
+ };
+ }
+
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java
new file mode 100644
index 0000000..e6072a4
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java
@@ -0,0 +1,4 @@
+package com.ecmsp.userservice.api.rest.auth;
+
+public record AuthRequest(String login, String password) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java
new file mode 100644
index 0000000..1f6f34e
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java
@@ -0,0 +1,4 @@
+package com.ecmsp.userservice.api.rest.auth;
+
+public record AuthResponse(String token) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java
new file mode 100644
index 0000000..c5e86ba
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java
@@ -0,0 +1,6 @@
+package com.ecmsp.userservice.api.rest.role;
+
+import com.ecmsp.userservice.user.domain.Permission;
+
+public record AddPermissionRequest(Permission permission) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java
new file mode 100644
index 0000000..066b5da
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java
@@ -0,0 +1,6 @@
+package com.ecmsp.userservice.api.rest.role;
+
+import java.util.UUID;
+
+public record AssignRoleRequest(UUID roleId) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java
new file mode 100644
index 0000000..a179c24
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java
@@ -0,0 +1,91 @@
+package com.ecmsp.userservice.api.rest.role;
+
+import com.ecmsp.userservice.user.domain.Role;
+import com.ecmsp.userservice.user.domain.RoleFacade;
+import com.ecmsp.userservice.user.domain.RoleId;
+import com.ecmsp.userservice.user.domain.RoleToCreate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/roles")
+public class RoleController {
+
+ private final RoleFacade roleFacade;
+
+ public RoleController(RoleFacade roleFacade) {
+ this.roleFacade = roleFacade;
+ }
+
+ @PostMapping
+ public ResponseEntity createRole(@RequestBody RoleCreateRequest request) {
+ RoleToCreate roleToCreate = new RoleToCreate(request.name(), request.permissions());
+ RoleId roleId = roleFacade.createRole(roleToCreate);
+
+ Role createdRole = roleFacade.findRoleById(roleId)
+ .orElseThrow(() -> new IllegalStateException("Role was not created"));
+
+ RoleResponse response = new RoleResponse(
+ createdRole.id().value(),
+ createdRole.name(),
+ createdRole.permissions()
+ );
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @GetMapping("/{roleId}")
+ public ResponseEntity getRoleById(@PathVariable UUID roleId) {
+ return roleFacade.findRoleById(new RoleId(roleId))
+ .map(role -> new RoleResponse(
+ role.id().value(),
+ role.name(),
+ role.permissions()
+ ))
+ .map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllRoles() {
+ List roles = roleFacade.getAllRoles().stream()
+ .map(role -> new RoleResponse(
+ role.id().value(),
+ role.name(),
+ role.permissions()
+ ))
+ .collect(Collectors.toList());
+
+ return ResponseEntity.ok(roles);
+ }
+
+ @PostMapping("/{roleId}/permissions")
+ public ResponseEntity addPermissionToRole(
+ @PathVariable UUID roleId,
+ @RequestBody AddPermissionRequest request) {
+ roleFacade.addPermissionToRole(new RoleId(roleId), request.permission());
+ return ResponseEntity.ok().build();
+ }
+
+ @DeleteMapping("/{roleId}/permissions/{permission}")
+ public ResponseEntity removePermissionFromRole(
+ @PathVariable UUID roleId,
+ @PathVariable String permission) {
+ roleFacade.removePermissionFromRole(
+ new RoleId(roleId),
+ com.ecmsp.userservice.user.domain.Permission.valueOf(permission)
+ );
+ return ResponseEntity.ok().build();
+ }
+
+ @DeleteMapping("/{roleId}")
+ public ResponseEntity deleteRole(@PathVariable UUID roleId) {
+ roleFacade.deleteRole(new RoleId(roleId));
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java
new file mode 100644
index 0000000..8f90b94
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java
@@ -0,0 +1,8 @@
+package com.ecmsp.userservice.api.rest.role;
+
+import com.ecmsp.userservice.user.domain.Permission;
+
+import java.util.Set;
+
+public record RoleCreateRequest(String name, Set permissions) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java
new file mode 100644
index 0000000..4fda4de
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java
@@ -0,0 +1,9 @@
+package com.ecmsp.userservice.api.rest.role;
+
+import com.ecmsp.userservice.user.domain.Permission;
+
+import java.util.Set;
+import java.util.UUID;
+
+public record RoleResponse(UUID id, String name, Set permissions) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java b/src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java
new file mode 100644
index 0000000..c5c361c
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java
@@ -0,0 +1,16 @@
+package com.ecmsp.userservice.application.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+
+@Configuration
+class ApplicationConfig {
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+
+
+}
diff --git a/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java
new file mode 100644
index 0000000..fc87579
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java
@@ -0,0 +1,35 @@
+package com.ecmsp.userservice.application.config;
+
+import com.github.dockerjava.api.model.ExposedPort;
+import com.github.dockerjava.api.model.PortBinding;
+import com.github.dockerjava.api.model.Ports;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.testcontainers.containers.PostgreSQLContainer;
+
+@Configuration
+@Profile("local")
+public class LocalPostgreSQLConfiguration {
+
+ @Bean
+ @ServiceConnection
+ PostgreSQLContainer> postgresContainer() {
+ return new PostgreSQLContainer<>("postgres:15")
+ .withDatabaseName("user-service-db")
+ .withCreateContainerCmdModifier(cmd ->
+ cmd
+ .withName("postgres-user-db")
+ .getHostConfig().withPortBindings(
+ new PortBinding(
+ /* hostPort = */ Ports.Binding.bindPort(9500),
+ /* containerPort = */ new ExposedPort(5432)
+ )
+ )
+ )
+ .withUsername("admin")
+ .withPassword("admin");
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java
new file mode 100644
index 0000000..296c780
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java
@@ -0,0 +1,49 @@
+package com.ecmsp.userservice.auth.adapter.generator;
+
+import com.ecmsp.userservice.auth.domain.Token;
+import com.ecmsp.userservice.auth.domain.TokenGenerator;
+import com.ecmsp.userservice.user.domain.Permission;
+import com.ecmsp.userservice.user.domain.User;
+import io.jsonwebtoken.Jwts;
+
+import java.security.PrivateKey;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class JwtTokenGenerator implements TokenGenerator {
+ private final PrivateKey privateKey;
+ private final Clock clock;
+
+
+ public JwtTokenGenerator(PrivateKey privateKey, Clock clock) {
+ this.privateKey = privateKey;
+ this.clock = clock;
+ }
+
+ @Override
+ public Token generate(User user) {
+ Instant now = clock.instant();
+ Instant expiration = now.plus(1, ChronoUnit.HOURS);
+
+ // Collect all permissions from all user roles
+ Set permissions = user.roles().stream()
+ .flatMap(role -> role.permissions().stream())
+ .map(Permission::name)
+ .collect(Collectors.toSet());
+
+ String jwt = Jwts.builder()
+ .subject(user.id().value().toString())
+ .claim("login", user.login())
+ .claim("permissions", permissions)
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(expiration))
+ .signWith(privateKey)
+ .compact();
+
+ return new Token(jwt);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java
new file mode 100644
index 0000000..8f4b313
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java
@@ -0,0 +1,42 @@
+package com.ecmsp.userservice.auth.adapter.generator;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Clock;
+import java.util.Base64;
+
+@Configuration
+@ConditionalOnProperty(
+ name = "auth.token-generator.type",
+ havingValue = "jwt"
+)
+class JwtTokenGeneratorConfig {
+
+ @Bean
+ public JwtTokenGenerator jwtTokenGenerator(Clock clock, @Value("${auth.token-generator.secret-key-file}") File secretKeyFile)
+ throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
+ String keyString = Files.readString(secretKeyFile.toPath()).trim();
+
+ // Remove PEM headers and footers if present
+ keyString = keyString.replaceAll("-----BEGIN PRIVATE KEY-----", "")
+ .replaceAll("-----END PRIVATE KEY-----", "")
+ .replaceAll("\\s", "");
+
+ byte[] keyBytes = Base64.getDecoder().decode(keyString);
+ PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+ PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
+
+ return new JwtTokenGenerator(privateKey, clock);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java b/src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java
new file mode 100644
index 0000000..6fb5807
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java
@@ -0,0 +1,15 @@
+package com.ecmsp.userservice.auth.config;
+
+import com.ecmsp.userservice.auth.domain.AuthFacade;
+import com.ecmsp.userservice.auth.domain.TokenGenerator;
+import com.ecmsp.userservice.user.domain.UserFacade;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+class AuthConfig {
+ @Bean
+ public AuthFacade authFacade(UserFacade userFacade, TokenGenerator generator){
+ return new AuthFacade(userFacade, generator);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java b/src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java
new file mode 100644
index 0000000..70b6e60
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java
@@ -0,0 +1,28 @@
+package com.ecmsp.userservice.auth.domain;
+
+import com.ecmsp.userservice.user.domain.UserFacade;
+import org.mindrot.jbcrypt.BCrypt;
+
+public class AuthFacade {
+ private final UserFacade userFacade;
+ private final TokenGenerator tokenGenerator;
+
+ public AuthFacade(UserFacade userFacade, TokenGenerator tokenGenerator) {
+ this.userFacade = userFacade;
+ this.tokenGenerator = tokenGenerator;
+ }
+
+ public AuthenticationResult authenticate(String login, String password) {
+ return userFacade.findUserByLogin(login)
+ .map(user -> {
+ boolean passwordMatches = BCrypt.checkpw(password, user.passwordHash());
+ if (passwordMatches) {
+ return new AuthenticationResult.Success(tokenGenerator.generate(user));
+ }
+ return new AuthenticationResult.Failure("Invalid credentials");
+ })
+ .orElse(new AuthenticationResult.Failure("User not found"));
+ }
+
+
+}
diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java b/src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java
new file mode 100644
index 0000000..e291d81
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java
@@ -0,0 +1,6 @@
+package com.ecmsp.userservice.auth.domain;
+
+public sealed interface AuthenticationResult {
+ record Success(Token token) implements AuthenticationResult {}
+ record Failure(String reason) implements AuthenticationResult {}
+}
diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/Token.java b/src/main/java/com/ecmsp/userservice/auth/domain/Token.java
new file mode 100644
index 0000000..455c0e8
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/domain/Token.java
@@ -0,0 +1,8 @@
+package com.ecmsp.userservice.auth.domain;
+
+public record Token(String value) {
+ @Override
+ public String toString() {
+ return value;
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java b/src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java
new file mode 100644
index 0000000..20e4866
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java
@@ -0,0 +1,7 @@
+package com.ecmsp.userservice.auth.domain;
+
+import com.ecmsp.userservice.user.domain.User;
+
+public interface TokenGenerator {
+ Token generate(User user);
+}
diff --git a/src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java b/src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java
new file mode 100644
index 0000000..51d3538
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java
@@ -0,0 +1,14 @@
+package com.ecmsp.userservice.role.config;
+
+import com.ecmsp.userservice.user.domain.RoleFacade;
+import com.ecmsp.userservice.user.domain.RoleRepository;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+class RoleConfig {
+ @Bean
+ public RoleFacade roleFacade(RoleRepository roleRepository) {
+ return new RoleFacade(roleRepository);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java
new file mode 100644
index 0000000..161852f
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java
@@ -0,0 +1,52 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import com.ecmsp.userservice.user.domain.Role;
+import com.ecmsp.userservice.user.domain.RoleId;
+import com.ecmsp.userservice.user.domain.RoleRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Repository
+class DbRoleRepository implements RoleRepository {
+
+ private final RoleEntityRepository roleEntityRepository;
+ private final RoleEntityMapper roleEntityMapper;
+
+ public DbRoleRepository(RoleEntityRepository roleEntityRepository) {
+ this.roleEntityRepository = roleEntityRepository;
+ this.roleEntityMapper = new RoleEntityMapper();
+ }
+
+ @Override
+ public void save(Role role) {
+ RoleEntity entity = roleEntityMapper.toRoleEntity(role);
+ roleEntityRepository.save(entity);
+ }
+
+ @Override
+ public Optional findById(RoleId roleId) {
+ return roleEntityRepository.findById(roleId.value())
+ .map(roleEntityMapper::toRole);
+ }
+
+ @Override
+ public Optional findByName(String name) {
+ return roleEntityRepository.findByRoleName(name)
+ .map(roleEntityMapper::toRole);
+ }
+
+ @Override
+ public List findAll() {
+ return roleEntityRepository.findAll().stream()
+ .map(roleEntityMapper::toRole)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void delete(RoleId roleId) {
+ roleEntityRepository.deleteById(roleId.value());
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java
new file mode 100644
index 0000000..ddc439b
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java
@@ -0,0 +1,66 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import com.ecmsp.userservice.user.domain.User;
+import com.ecmsp.userservice.user.domain.UserId;
+import com.ecmsp.userservice.user.domain.UserRepository;
+import com.ecmsp.userservice.user.domain.UserView;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+class DbUserRepository implements UserRepository {
+ private final UserEntityMapper userMapper = new UserEntityMapper();
+ private final UserEntityRepository userEntityRepository;
+
+ DbUserRepository(UserEntityRepository userEntityRepository) {
+ this.userEntityRepository = userEntityRepository;
+ }
+
+
+ @Override
+ public void save(User user) {
+ if(userEntityRepository.existsById(user.id().value())) {
+ throw new RuntimeException("User already exists: " + user.id());
+ }
+ userEntityRepository.save(userMapper.toUserEntity(user));
+ }
+
+ @Override
+ public Optional findById(UserId userId) {
+ return userEntityRepository.findById(userId.value())
+ .map(userMapper::toUser);
+ }
+
+ @Override
+ public Optional findByLogin(String login) {
+ return userEntityRepository.findByLogin(login)
+ .map(userMapper::toUser);
+ }
+
+ @Override
+ public void deleteById(UserId userId) {
+ userEntityRepository.deleteById(userId.value());
+ }
+
+ @Override
+ public List findAll() {
+ return userEntityRepository.findAll().stream()
+ .map(userMapper::toUserView)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public List findByLoginContaining(String loginFilter) {
+ return userEntityRepository.findByLoginContaining(loginFilter).stream()
+ .map(userMapper::toUserView)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ @Transactional
+ public void updateLogin(UserId userId, String newLogin) {
+ userEntityRepository.updateLogin(userId.value(), newLogin);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java
new file mode 100644
index 0000000..71f0bfe
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java
@@ -0,0 +1,26 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import com.ecmsp.userservice.user.domain.UserRepository;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+
+@Configuration
+@ConditionalOnProperty(
+ prefix = "user.repository",
+ name = "type",
+ havingValue = "db")
+@Import({DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@EnableJpaRepositories(basePackages = "com.ecmsp.userservice.user.adapter.repository.db")
+class DbUserRepositoryConfiguration {
+ @Bean
+ UserRepository dbUserRepository(UserEntityRepository userEntityRepository) {
+ return new DbUserRepository(
+ /* orderEntityRepository = */ userEntityRepository
+ );
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java
new file mode 100644
index 0000000..43fd845
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java
@@ -0,0 +1,26 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@Entity
+@Table(name = "permissions")
+class PermissionEntity {
+
+ @Id
+ @Column(name = "permission_name")
+ private String permissionName;
+
+ public String getPermissionName() {
+ return permissionName;
+ }
+
+ public void setPermissionName(String permissionName) {
+ this.permissionName = permissionName;
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java
new file mode 100644
index 0000000..6cf28ae
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java
@@ -0,0 +1,57 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@Entity
+@Table(name = "roles")
+class RoleEntity {
+
+ @Id
+ @Column(name = "role_id")
+ private UUID roleId;
+
+ @Column(name = "role_name", nullable = false, unique = true)
+ private String roleName;
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "role_permissions",
+ joinColumns = @JoinColumn(name = "role_id"),
+ inverseJoinColumns = @JoinColumn(name = "permission_name")
+ )
+ private Set permissions = new HashSet<>();
+
+ public UUID getRoleId() {
+ return roleId;
+ }
+
+ public void setRoleId(UUID roleId) {
+ this.roleId = roleId;
+ }
+
+ public String getRoleName() {
+ return roleName;
+ }
+
+ public void setRoleName(String roleName) {
+ this.roleName = roleName;
+ }
+
+ public Set getPermissions() {
+ return permissions;
+ }
+
+ public void setPermissions(Set permissions) {
+ this.permissions = permissions;
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java
new file mode 100644
index 0000000..1338f8e
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java
@@ -0,0 +1,37 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import com.ecmsp.userservice.user.domain.Permission;
+import com.ecmsp.userservice.user.domain.Role;
+import com.ecmsp.userservice.user.domain.RoleId;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class RoleEntityMapper {
+
+ public Role toRole(RoleEntity entity) {
+ Set permissions = entity.getPermissions().stream()
+ .map(permissionEntity -> Permission.valueOf(permissionEntity.getPermissionName()))
+ .collect(Collectors.toSet());
+
+ return new Role(
+ new RoleId(entity.getRoleId()),
+ entity.getRoleName(),
+ permissions
+ );
+ }
+
+ public RoleEntity toRoleEntity(Role role) {
+ Set permissionEntities = role.permissions().stream()
+ .map(permission -> PermissionEntity.builder()
+ .permissionName(permission.name())
+ .build())
+ .collect(Collectors.toSet());
+
+ return RoleEntity.builder()
+ .roleId(role.id().value())
+ .roleName(role.name())
+ .permissions(permissionEntities)
+ .build();
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java
new file mode 100644
index 0000000..1e8d548
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java
@@ -0,0 +1,10 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+interface RoleEntityRepository extends JpaRepository {
+ Optional findByRoleName(String roleName);
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java
new file mode 100644
index 0000000..9ebed41
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java
@@ -0,0 +1,80 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@Entity
+@Table(name = "users")
+class UserEntity {
+
+ @Id
+ @Column(name = "user_id")
+ private UUID userId;
+
+ //TODO change it to username or email or keep both
+ @Column(name = "login")
+ private String login;
+
+ //it's already hashed
+ @Column(name = "password")
+ private String password;
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "user_roles",
+ joinColumns = @JoinColumn(name = "user_id"),
+ inverseJoinColumns = @JoinColumn(name = "role_id")
+ )
+ private Set roles = new HashSet<>();
+
+ public UUID getUserId() {
+ return userId;
+ }
+
+ public void setUserId(UUID userId) {
+ this.userId = userId;
+ }
+
+ public String getLogin() {
+ return login;
+ }
+
+ public void setLogin(String login) {
+ this.login = login;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public Set getRoles() {
+ return roles;
+ }
+
+ public void setRoles(Set roles) {
+ this.roles = roles;
+ }
+
+ @Override
+ public String toString() {
+ return "UserEntity{" +
+ "userId=" + userId +
+ ", login='" + login + '\'' +
+ ", password='" + password + '\'' +
+ ", roles=" + roles +
+ '}';
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java
new file mode 100644
index 0000000..c16ce74
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java
@@ -0,0 +1,56 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import com.ecmsp.userservice.user.domain.Role;
+import com.ecmsp.userservice.user.domain.User;
+import com.ecmsp.userservice.user.domain.UserId;
+import com.ecmsp.userservice.user.domain.UserView;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class UserEntityMapper {
+
+ private final RoleEntityMapper roleEntityMapper;
+
+ public UserEntityMapper() {
+ this.roleEntityMapper = new RoleEntityMapper();
+ }
+
+ public User toUser(UserEntity entity) {
+ Set roles = entity.getRoles().stream()
+ .map(roleEntityMapper::toRole)
+ .collect(Collectors.toSet());
+
+ return new User(
+ new UserId(entity.getUserId()),
+ entity.getLogin(),
+ entity.getPassword(),
+ roles
+ );
+ }
+
+ public UserView toUserView(UserEntity entity) {
+ Set roles = entity.getRoles().stream()
+ .map(roleEntityMapper::toRole)
+ .collect(Collectors.toSet());
+
+ return new UserView(
+ new UserId(entity.getUserId()),
+ entity.getLogin(),
+ roles
+ );
+ }
+
+ public UserEntity toUserEntity(User user) {
+ Set roleEntities = user.roles().stream()
+ .map(roleEntityMapper::toRoleEntity)
+ .collect(Collectors.toSet());
+
+ return UserEntity.builder()
+ .userId(user.id().value())
+ .login(user.login())
+ .password(user.passwordHash())
+ .roles(roleEntities)
+ .build();
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java
new file mode 100644
index 0000000..1f0bead
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java
@@ -0,0 +1,23 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+@Repository
+interface UserEntityRepository extends JpaRepository {
+ @Query("SELECT u FROM UserEntity u WHERE u.login = :login")
+ Optional findByLogin(String login);
+
+ @Query("SELECT u FROM UserEntity u WHERE LOWER(u.login) LIKE LOWER(CONCAT('%', :loginFilter, '%'))")
+ List findByLoginContaining(String loginFilter);
+
+ @Modifying
+ @Query("UPDATE UserEntity u SET u.login = :newLogin WHERE u.userId = :userId")
+ void updateLogin(UUID userId, String newLogin);
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java
new file mode 100644
index 0000000..bb8e340
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java
@@ -0,0 +1,68 @@
+package com.ecmsp.userservice.user.adapter.repository.inmemory;
+
+import com.ecmsp.userservice.user.domain.User;
+import com.ecmsp.userservice.user.domain.UserId;
+import com.ecmsp.userservice.user.domain.UserRepository;
+import com.ecmsp.userservice.user.domain.UserView;
+import org.mindrot.jbcrypt.BCrypt;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+class InMemoryUserRepository implements UserRepository {
+ private final Map users = new HashMap<>();
+
+ public InMemoryUserRepository() {
+ // Initialize with test user
+ UserId testUserId = new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94"));
+ User testUser = new User(testUserId, "testuser", BCrypt.hashpw("testpassword", BCrypt.gensalt()), Set.of());
+ users.put(testUserId, testUser);
+ }
+
+ @Override
+ public void save(User user) {
+ users.put(user.id(), user);
+ }
+
+ @Override
+ public Optional findById(UserId userId) {
+ User user = users.get(userId);
+ return Optional.ofNullable(user);
+ }
+
+ @Override
+ public Optional findByLogin(String login) {
+ return users.values().stream()
+ .filter(user -> user.login().equals(login))
+ .findFirst();
+ }
+
+ @Override
+ public void deleteById(UserId userId) {
+ users.remove(userId);
+ }
+
+ @Override
+ public List findAll() {
+ return users.values().stream()
+ .map(user -> new UserView(user.id(), user.login(), user.roles()))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public List findByLoginContaining(String loginFilter) {
+ return users.values().stream()
+ .filter(user -> user.login().toLowerCase().contains(loginFilter.toLowerCase()))
+ .map(user -> new UserView(user.id(), user.login(), user.roles()))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void updateLogin(UserId userId, String newLogin) {
+ User user = users.get(userId);
+ if (user != null) {
+ User updatedUser = new User(user.id(), newLogin, user.passwordHash(), user.roles());
+ users.put(userId, updatedUser);
+ }
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java
new file mode 100644
index 0000000..30bfcc4
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java
@@ -0,0 +1,15 @@
+package com.ecmsp.userservice.user.adapter.repository.inmemory;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConditionalOnProperty(value = "user.repository.type", havingValue = "in-memory")
+class InMemoryUserRepositoryConfig {
+
+ @Bean
+ InMemoryUserRepository inMemoryUserRepository() {
+ return new InMemoryUserRepository();
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java
new file mode 100644
index 0000000..0090dd9
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java
@@ -0,0 +1,15 @@
+package com.ecmsp.userservice.user.config;
+
+import com.ecmsp.userservice.user.domain.RoleRepository;
+import com.ecmsp.userservice.user.domain.UserFacade;
+import com.ecmsp.userservice.user.domain.UserRepository;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+class UserConfig {
+ @Bean
+ public UserFacade userFacade(UserRepository userRepository, RoleRepository roleRepository){
+ return new UserFacade(userRepository, roleRepository);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java b/src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java
new file mode 100644
index 0000000..afc68ff
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java
@@ -0,0 +1,11 @@
+package com.ecmsp.userservice.user.domain;
+
+import org.mindrot.jbcrypt.BCrypt;
+
+class PasswordHasher {
+ String hash(String password) {
+ return BCrypt.hashpw(password, BCrypt.gensalt());
+ }
+
+
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/Permission.java b/src/main/java/com/ecmsp/userservice/user/domain/Permission.java
new file mode 100644
index 0000000..3ca403e
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/Permission.java
@@ -0,0 +1,22 @@
+package com.ecmsp.userservice.user.domain;
+
+public enum Permission {
+ // Product permissions
+ WRITE_PRODUCTS,
+ DELETE_PRODUCTS,
+
+ // Order permissions
+ READ_ORDERS,
+ WRITE_ORDERS,
+ CANCEL_ORDERS,
+
+ // User management permissions
+ MANAGE_USERS,
+
+ // Role management permissions
+ MANAGE_ROLES,
+
+ // Payment permissions
+ PROCESS_PAYMENTS,
+ REFUND_PAYMENTS
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/Role.java b/src/main/java/com/ecmsp/userservice/user/domain/Role.java
new file mode 100644
index 0000000..9fbd364
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/Role.java
@@ -0,0 +1,41 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+public class Role {
+ private final RoleId id;
+ private final String name;
+ private final Set permissions;
+
+ public Role(RoleId id, String name, Set permissions) {
+ this.id = id;
+ this.name = name;
+ this.permissions = new HashSet<>(permissions);
+ }
+
+ public RoleId id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public Set permissions() {
+ return Collections.unmodifiableSet(permissions);
+ }
+
+ public void addPermission(Permission permission) {
+ permissions.add(permission);
+ }
+
+ public void removePermission(Permission permission) {
+ permissions.remove(permission);
+ }
+
+ public boolean hasPermission(Permission permission) {
+ return permissions.contains(permission);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java
new file mode 100644
index 0000000..3562ae9
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java
@@ -0,0 +1,50 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public class RoleFacade {
+ private final RoleRepository roleRepository;
+
+ public RoleFacade(RoleRepository roleRepository) {
+ this.roleRepository = roleRepository;
+ }
+
+ public RoleId createRole(RoleToCreate roleToCreate) {
+ RoleId roleId = new RoleId(UUID.randomUUID());
+ Role role = new Role(roleId, roleToCreate.name(), roleToCreate.permissions());
+ roleRepository.save(role);
+ return roleId;
+ }
+
+ public void addPermissionToRole(RoleId roleId, Permission permission) {
+ Role role = roleRepository.findById(roleId)
+ .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleId));
+ role.addPermission(permission);
+ roleRepository.save(role);
+ }
+
+ public void removePermissionFromRole(RoleId roleId, Permission permission) {
+ Role role = roleRepository.findById(roleId)
+ .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleId));
+ role.removePermission(permission);
+ roleRepository.save(role);
+ }
+
+ public Optional findRoleById(RoleId roleId) {
+ return roleRepository.findById(roleId);
+ }
+
+ public Optional findRoleByName(String name) {
+ return roleRepository.findByName(name);
+ }
+
+ public List getAllRoles() {
+ return roleRepository.findAll();
+ }
+
+ public void deleteRole(RoleId roleId) {
+ roleRepository.delete(roleId);
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleId.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleId.java
new file mode 100644
index 0000000..8ca483a
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleId.java
@@ -0,0 +1,6 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.UUID;
+
+public record RoleId(UUID value) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java
new file mode 100644
index 0000000..7ac24eb
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java
@@ -0,0 +1,12 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface RoleRepository {
+ void save(Role role);
+ Optional findById(RoleId roleId);
+ Optional findByName(String name);
+ List findAll();
+ void delete(RoleId roleId);
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java
new file mode 100644
index 0000000..af8ec84
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java
@@ -0,0 +1,6 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.Set;
+
+public record RoleToCreate(String name, Set permissions) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/User.java b/src/main/java/com/ecmsp/userservice/user/domain/User.java
new file mode 100644
index 0000000..97c3eb4
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/User.java
@@ -0,0 +1,7 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.Set;
+
+public record User(UserId id, String login, String passwordHash, Set roles) {
+
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java
new file mode 100644
index 0000000..bd52c57
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java
@@ -0,0 +1,81 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+public class UserFacade {
+ private final UserRepository userRepository;
+ private final RoleRepository roleRepository;
+ private final PasswordHasher passwordHasher = new PasswordHasher();
+
+ public UserFacade(UserRepository userRepository, RoleRepository roleRepository) {
+ this.userRepository = userRepository;
+ this.roleRepository = roleRepository;
+ }
+
+ public User createUser(UserToCreate userToCreate){
+ UserId userId = new UserId(UUID.randomUUID());
+ String hashedPassword = passwordHasher.hash(userToCreate.password());
+ User user = new User(userId, userToCreate.login(), hashedPassword, new HashSet<>());
+ userRepository.save(user);
+ return user;
+ }
+
+ public Optional findUserById(UserId userId){
+ return userRepository.findById(userId);
+ }
+
+ public Optional findUserByLogin(String login) {
+ return userRepository.findByLogin(login);
+ }
+
+ public void updateUserLogin(UserId userId, String newLogin) {
+ userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
+ userRepository.updateLogin(userId, newLogin);
+ }
+
+ public void deleteUser(UserId userId) {
+ userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
+ userRepository.deleteById(userId);
+ }
+
+ public List listUsers(String filterLogin) {
+ if (filterLogin == null || filterLogin.isBlank()) {
+ return userRepository.findAll();
+ }
+ return userRepository.findByLoginContaining(filterLogin);
+ }
+
+ public void assignRoleToUser(UserId userId, RoleId roleId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
+ Role role = roleRepository.findById(roleId)
+ .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleId));
+
+ Set updatedRoles = new HashSet<>(user.roles());
+ updatedRoles.add(role);
+ User updatedUser = new User(user.id(), user.login(), user.passwordHash(), updatedRoles);
+ userRepository.save(updatedUser);
+ }
+
+ public void removeRoleFromUser(UserId userId, RoleId roleId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
+
+ Set updatedRoles = new HashSet<>(user.roles());
+ updatedRoles.removeIf(role -> role.id().equals(roleId));
+ User updatedUser = new User(user.id(), user.login(), user.passwordHash(), updatedRoles);
+ userRepository.save(updatedUser);
+ }
+
+ public Set getUserRoles(UserId userId) {
+ return userRepository.findById(userId)
+ .map(User::roles)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserId.java b/src/main/java/com/ecmsp/userservice/user/domain/UserId.java
new file mode 100644
index 0000000..2ffc6bf
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/UserId.java
@@ -0,0 +1,11 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.UUID;
+
+public record UserId(UUID value) {
+
+ @Override
+ public String toString() {
+ return value.toString();
+ }
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java
new file mode 100644
index 0000000..7e110f0
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java
@@ -0,0 +1,14 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface UserRepository {
+ void save(User user);
+ Optional findById(UserId userId);
+ Optional findByLogin(String login);
+ void deleteById(UserId userId);
+ List findAll();
+ List findByLoginContaining(String loginFilter);
+ void updateLogin(UserId userId, String newLogin);
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java b/src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java
new file mode 100644
index 0000000..96f08ca
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java
@@ -0,0 +1,4 @@
+package com.ecmsp.userservice.user.domain;
+
+public record UserToCreate(String login, String password) {
+}
diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserView.java b/src/main/java/com/ecmsp/userservice/user/domain/UserView.java
new file mode 100644
index 0000000..864841a
--- /dev/null
+++ b/src/main/java/com/ecmsp/userservice/user/domain/UserView.java
@@ -0,0 +1,6 @@
+package com.ecmsp.userservice.user.domain;
+
+import java.util.Set;
+
+public record UserView(UserId id, String login, Set roles) {
+}
diff --git a/src/main/resources/application-compose.properties b/src/main/resources/application-compose.properties
new file mode 100644
index 0000000..818d5dd
--- /dev/null
+++ b/src/main/resources/application-compose.properties
@@ -0,0 +1,11 @@
+spring.datasource.url=jdbc:postgresql://user-service-db:5432/user-service-db
+
+spring.kafka.bootstrap-servers=kafka:9092
+
+spring.kafka.consumer.group-id=user-service
+spring.kafka.consumer.auto-offset-reset=earliest
+spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
+spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
+
+spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
+spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
\ No newline at end of file
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
new file mode 100644
index 0000000..ef24ef5
--- /dev/null
+++ b/src/main/resources/application-dev.properties
@@ -0,0 +1,13 @@
+spring.datasource.url=jdbc:postgresql://localhost:9500/user-service-db
+spring.datasource.username=admin
+spring.datasource.password=admin
+
+spring.kafka.bootstrap-servers=localhost:9094
+
+spring.kafka.consumer.group-id=user-service
+spring.kafka.consumer.auto-offset-reset=earliest
+spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
+spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
+
+spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
+spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
\ No newline at end of file
diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml
new file mode 100644
index 0000000..b3c8937
--- /dev/null
+++ b/src/main/resources/application-local.yml
@@ -0,0 +1,9 @@
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:9500/user-service-db
+ username: admin
+ password: admin
+
+user:
+ repository:
+ type: db
\ No newline at end of file
diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties
new file mode 100644
index 0000000..3c82733
--- /dev/null
+++ b/src/main/resources/application-prod.properties
@@ -0,0 +1,13 @@
+spring.datasource.url=
+spring.datasource.username=
+spring.datasource.password=
+
+spring.kafka.bootstrap-servers=
+
+spring.kafka.consumer.group-id=user-service
+spring.kafka.consumer.auto-offset-reset=earliest
+spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
+spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
+
+spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
+spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index f9c237e..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-spring.application.name=User service
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..826d744
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,32 @@
+spring:
+ application:
+ name: User service
+ jpa:
+ hibernate.ddl-auto: validate
+ properties.hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
+ show-sql: true
+ datasource:
+ url: ${DB_URL}
+ username: ${DB_USERNAME}
+ password: ${DB_PASSWORD}
+ flyway:
+ enabled: true
+ locations: classpath:db/migration
+ baseline-on-migrate: true
+auth:
+ token-generator:
+ type: jwt
+ secret-key-file: classpath:local/secrets/local.private.pkcs8.key
+user:
+ repository:
+ type: db
+
+
+server:
+ port: 8500
+
+grpc:
+ server:
+ port: 7500
+ security:
+ enabled: false # Set to true to enable TLS
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V1__Create_initial_schema.sql b/src/main/resources/db/migration/V1__Create_initial_schema.sql
new file mode 100644
index 0000000..f9f8f27
--- /dev/null
+++ b/src/main/resources/db/migration/V1__Create_initial_schema.sql
@@ -0,0 +1,10 @@
+
+CREATE TABLE users
+(
+ user_id UUID PRIMARY KEY,
+ login VARCHAR(100) NOT NULL,
+ password VARCHAR(100) NOT NULL
+);
+
+
+CREATE INDEX idx_users_login ON users (login);
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V2__Insert_example_data.sql b/src/main/resources/db/migration/V2__Insert_example_data.sql
new file mode 100644
index 0000000..90db716
--- /dev/null
+++ b/src/main/resources/db/migration/V2__Insert_example_data.sql
@@ -0,0 +1,10 @@
+-- Insert example users for testing and development
+
+INSERT INTO users (user_id, login, password) VALUES
+ ('550e8400-e29b-41d4-a716-446655440000', 'john_doe', '$2a$10$N9qo8uLOickgx2ZMRZoMye7VPKRdQqYBBgOFGhqxfDGqCxo8Ckzq2'), -- password: password123
+ ('550e8400-e29b-41d4-a716-446655440001', 'jane_smith', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'), -- password: secret456
+ ('550e8400-e29b-41d4-a716-446655440002', 'admin_user', '$2a$10$DOwJoMGp.NiaVisaQeK2eOhZYkyiwQQw2O.CzpG9k4CrcSKTF9q8e'), -- password: admin789
+ ('123e4567-e89b-12d3-a456-426614174001', 'andy', '$2a$12$Oox0qAgn0eVRfyNEy1CGIeSRJ9lmMVl7kWaZ5/UPKkWz0c28ZAzDq'); -- password: password123
+
+-- Note: Passwords are hashed using BCrypt with salt rounds = 10
+-- These are example hashes for demonstration purposes
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql
new file mode 100644
index 0000000..54f67a2
--- /dev/null
+++ b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql
@@ -0,0 +1,41 @@
+-- Create permissions table
+CREATE TABLE permissions (
+ permission_name VARCHAR(100) PRIMARY KEY
+);
+
+-- Create roles table
+CREATE TABLE roles (
+ role_id UUID PRIMARY KEY,
+ role_name VARCHAR(100) NOT NULL UNIQUE
+);
+
+-- Create role_permissions join table
+CREATE TABLE role_permissions (
+ role_id UUID REFERENCES roles(role_id) ON DELETE CASCADE,
+ permission_name VARCHAR(100) REFERENCES permissions(permission_name) ON DELETE CASCADE,
+ PRIMARY KEY (role_id, permission_name)
+);
+
+-- Create user_roles join table
+CREATE TABLE user_roles (
+ user_id UUID REFERENCES users(user_id) ON DELETE CASCADE,
+ role_id UUID REFERENCES roles(role_id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, role_id)
+);
+
+-- Insert predefined permissions
+INSERT INTO permissions (permission_name) VALUES
+ ('WRITE_PRODUCTS'),
+ ('DELETE_PRODUCTS'),
+ ('READ_ORDERS'),
+ ('WRITE_ORDERS'),
+ ('CANCEL_ORDERS'),
+ ('MANAGE_USERS'),
+ ('MANAGE_ROLES'),
+ ('PROCESS_PAYMENTS'),
+ ('REFUND_PAYMENTS');
+
+-- Create indexes for performance
+CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id);
+CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
+CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
diff --git a/src/main/resources/local/secrets/INSTALL b/src/main/resources/local/secrets/INSTALL
new file mode 100644
index 0000000..49a1168
--- /dev/null
+++ b/src/main/resources/local/secrets/INSTALL
@@ -0,0 +1,4 @@
+Generate private/public RSA4096 key pair:
+
+openssl genrsa > local.private.key
+openssl rsa -in local.private.key -pubout > local.public.key
\ No newline at end of file
diff --git a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java
index e9f320f..6a5d977 100644
--- a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java
+++ b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java
@@ -2,12 +2,13 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class UserServiceApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
+import org.springframework.test.context.ActiveProfiles;
+
+//@ActiveProfiles("test")
+//@SpringBootTest
+//class UserServiceApplicationTests {
+// @Test
+// void contextLoads() {
+// }
+//
+//}
diff --git a/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java
new file mode 100644
index 0000000..a3cc0da
--- /dev/null
+++ b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java
@@ -0,0 +1,186 @@
+package com.ecmsp.userservice.auth.adapter.generator;
+
+import com.ecmsp.userservice.auth.domain.Token;
+import com.ecmsp.userservice.user.domain.User;
+import com.ecmsp.userservice.user.domain.UserId;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class JwtTokenGeneratorTest {
+
+ private JwtTokenGenerator jwtTokenGenerator;
+ private PrivateKey privateKey;
+ private PublicKey publicKey;
+ private Clock fixedClock;
+ private final Instant fixedInstant = Instant.now();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ privateKey = keyPair.getPrivate();
+ publicKey = keyPair.getPublic();
+ fixedClock = Clock.fixed(fixedInstant, ZoneOffset.UTC);
+
+ jwtTokenGenerator = new JwtTokenGenerator(privateKey, fixedClock);
+ }
+ @Test
+ void shouldGenerateValidJwtToken() {
+ UUID userId = UUID.fromString("123e4567-e89b-12d3-a456-426614174001");
+ String login = "andy";
+ User user = new User(new UserId(userId), login, "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ assertNotNull(token);
+ assertNotNull(token.value());
+ assertFalse(token.value().isEmpty());
+ }
+
+ @Test
+ void shouldIncludeCorrectSubjectInToken() {
+ UUID userId = UUID.randomUUID();
+ String login = "testuser";
+ User user = new User(new UserId(userId), login, "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ Claims claims = parseTokenClaims(token.value());
+ assertEquals(userId.toString(), claims.getSubject());
+ }
+
+ @Test
+ void shouldIncludeLoginClaimInToken() {
+ UUID userId = UUID.randomUUID();
+ String login = "testuser";
+ User user = new User(new UserId(userId), login, "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ Claims claims = parseTokenClaims(token.value());
+ assertEquals(login, claims.get("login"));
+ }
+
+ @Test
+ void shouldSetCorrectIssuedAtTime() {
+ UUID userId = UUID.randomUUID();
+ User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ Claims claims = parseTokenClaims(token.value());
+ long expectedTime = fixedInstant.getEpochSecond();
+ long actualTime = claims.getIssuedAt().toInstant().getEpochSecond();
+ assertEquals(expectedTime, actualTime);
+ }
+
+ @Test
+ void shouldSetExpirationTimeOneHourFromIssuedAt() {
+ UUID userId = UUID.randomUUID();
+ User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ Claims claims = parseTokenClaims(token.value());
+ long expectedExpirationTime = fixedInstant.plusSeconds(3600).getEpochSecond(); // 1 hour
+ long actualExpirationTime = claims.getExpiration().toInstant().getEpochSecond();
+ assertEquals(expectedExpirationTime, actualExpirationTime);
+ }
+
+ @Test
+ void shouldSignTokenWithProvidedPrivateKey() {
+ UUID userId = UUID.randomUUID();
+ User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ assertDoesNotThrow(() -> {
+ Jwts.parser()
+ .verifyWith(publicKey)
+ .build()
+ .parseSignedClaims(token.value());
+ });
+ }
+
+ @Test
+ void shouldThrowExceptionWhenVerifyingWithWrongKey() throws Exception {
+ UUID userId = UUID.randomUUID();
+ User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair wrongKeyPair = keyPairGenerator.generateKeyPair();
+ PublicKey wrongPublicKey = wrongKeyPair.getPublic();
+
+ assertThrows(Exception.class, () -> Jwts.parser()
+ .verifyWith(wrongPublicKey)
+ .build()
+ .parseSignedClaims(token.value()));
+ }
+
+ @Test
+ void shouldGenerateDifferentTokensForDifferentUsers() {
+ User user1 = new User(new UserId(UUID.randomUUID()), "user1", "password1", Set.of());
+ User user2 = new User(new UserId(UUID.randomUUID()), "user2", "password2", Set.of());
+
+ Token token1 = jwtTokenGenerator.generate(user1);
+ Token token2 = jwtTokenGenerator.generate(user2);
+
+ assertNotEquals(token1.value(), token2.value());
+ }
+
+ @Test
+ void shouldGenerateDifferentTokensAtDifferentTimes() {
+ UUID userId = UUID.randomUUID();
+ User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of());
+
+ Token token1 = jwtTokenGenerator.generate(user);
+
+ Clock laterClock = Clock.fixed(fixedInstant.plusSeconds(60), ZoneOffset.UTC);
+ JwtTokenGenerator laterGenerator = new JwtTokenGenerator(privateKey, laterClock);
+ Token token2 = laterGenerator.generate(user);
+
+ assertNotEquals(token1.value(), token2.value());
+ }
+
+ @Test
+ void shouldGenerateCompactJwtFormat() {
+ UUID userId = UUID.randomUUID();
+ User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of());
+
+ Token token = jwtTokenGenerator.generate(user);
+
+ String[] parts = token.value().split("\\.");
+ assertEquals(3, parts.length, "JWT should have 3 parts separated by dots");
+
+ for (String part : parts) {
+ assertFalse(part.isEmpty(), "Each JWT part should not be empty");
+ }
+ }
+
+ private Claims parseTokenClaims(String token) {
+ return Jwts.parser()
+ .verifyWith(publicKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java
new file mode 100644
index 0000000..55996ce
--- /dev/null
+++ b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java
@@ -0,0 +1,214 @@
+package com.ecmsp.userservice.user.adapter.repository.db;
+
+import com.ecmsp.userservice.user.domain.*;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DataJpaTest
+@ActiveProfiles("test")
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
+class DbUserRepositoryTest {
+
+ private static final UserId USER_1_ID = new UserId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"));
+ private static final UserId USER_2_ID = new UserId(UUID.fromString("9e349a18-1203-4224-829c-dc15700c68a5"));
+
+ private static final User USER_1 = new User(
+ /* id = */ USER_1_ID,
+ /* login = */ "user1",
+ /* passwordHash = */ "hashedPassword1",
+ /* roles = */ Set.of()
+ );
+
+ private static final User USER_2 = new User(
+ /* id = */ USER_2_ID,
+ /* login = */ "user2",
+ /* passwordHash = */ "hashedPassword2",
+ Set.of()
+ );
+
+ @Autowired
+ private UserEntityRepository userEntityRepository;
+
+ @Autowired
+ private TestEntityManager testEntityManager;
+
+ @Test
+ void should_save_new_user() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // when:
+ repository.save(USER_1);
+
+ // then:
+ User savedUser = repository.findById(USER_1_ID).orElseThrow();
+ assertThat(savedUser).isEqualTo(USER_1);
+
+ // and:
+ UserEntity userEntity = testEntityManager.find(UserEntity.class, USER_1_ID.value());
+ assertThat(userEntity).isNotNull();
+ assertThat(userEntity.getUserId()).isEqualTo(USER_1_ID.value());
+ assertThat(userEntity.getLogin()).isEqualTo("user1");
+ assertThat(userEntity.getPassword()).isEqualTo("hashedPassword1");
+ }
+
+ @Test
+ void should_throw_exception_when_save_user_with_existing_id() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // and:
+ testEntityManager.persistAndFlush(
+ UserEntity.builder()
+ .userId(USER_1_ID.value())
+ .login("user1")
+ .password("hashedPassword1")
+ .build()
+ );
+
+ // when:
+ var error = assertThatThrownBy(() -> {
+ repository.save(USER_1); // trying to save user with the same ID
+ });
+
+ // then:
+ error.isInstanceOf(RuntimeException.class);
+ error.hasMessageContaining("User already exists: %s".formatted(USER_1_ID));
+ }
+
+ @Test
+ void should_find_user_by_id() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // and:
+ testEntityManager.persistAndFlush(
+ UserEntity.builder()
+ .userId(USER_1_ID.value())
+ .login("user1")
+ .password("hashedPassword1")
+ .roles(Set.of())
+ .build()
+ );
+
+ // when:
+ Optional user = repository.findById(USER_1_ID);
+
+ // then:
+ assertThat(user).isPresent();
+ assertThat(user.get()).isEqualTo(USER_1);
+ }
+
+ @Test
+ void should_return_empty_optional_when_user_with_given_id_not_exist() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // and:
+ testEntityManager.persistAndFlush(
+ UserEntity.builder()
+ .userId(USER_1_ID.value())
+ .login("user1")
+ .password("hashedPassword1")
+ .roles(Set.of())
+ .build()
+ );
+
+ // when:
+ Optional user = repository.findById(USER_2_ID); // USER_2_ID does not exist
+
+ // then:
+ assertThat(user).isEmpty();
+ }
+
+ @Test
+ void should_find_user_by_login() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // and:
+ testEntityManager.persistAndFlush(
+ UserEntity.builder()
+ .userId(USER_1_ID.value())
+ .login("user1")
+ .password("hashedPassword1")
+ .roles(Set.of())
+ .build()
+ );
+
+ // when:
+ Optional user = repository.findByLogin("user1");
+
+ // then:
+ assertThat(user).isPresent();
+ assertThat(user.get()).isEqualTo(USER_1);
+ }
+
+ @Test
+ void should_return_empty_optional_when_user_with_given_login_not_exist() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // and:
+ testEntityManager.persistAndFlush(
+ UserEntity.builder()
+ .userId(USER_1_ID.value())
+ .login("user1")
+ .password("hashedPassword1")
+ .build()
+ );
+
+ // when:
+ Optional user = repository.findByLogin("nonexistentuser"); // login does not exist
+
+ // then:
+ assertThat(user).isEmpty();
+ }
+
+ @Test
+ void should_find_correct_user_when_multiple_users_exist() {
+ // given:
+ UserRepository repository = new DbUserRepository(userEntityRepository);
+
+ // and:
+ testEntityManager.persist(
+ UserEntity.builder()
+ .userId(USER_1_ID.value())
+ .login("user1")
+ .password("hashedPassword1")
+ .roles(Set.of())
+ .build()
+ );
+ testEntityManager.persist(
+ UserEntity.builder()
+ .userId(USER_2_ID.value())
+ .login("user2")
+ .password("hashedPassword2")
+ .roles(Set.of())
+ .build()
+ );
+ testEntityManager.flush();
+
+ // when:
+ Optional user1 = repository.findByLogin("user1");
+ Optional user2 = repository.findByLogin("user2");
+
+ // then:
+ assertThat(user1).isPresent();
+ assertThat(user1.get()).isEqualTo(USER_1);
+ assertThat(user2).isPresent();
+ assertThat(user2.get()).isEqualTo(USER_2);
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
new file mode 100644
index 0000000..15116a7
--- /dev/null
+++ b/src/test/resources/application-test.yml
@@ -0,0 +1,25 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
+ driver-class-name: org.h2.Driver
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.H2Dialect
+ show-sql: true
+ flyway:
+ enabled: true
+ locations: classpath:db/migration
+ baseline-on-migrate: true
+
+user:
+ repository:
+ type: db
+
+auth:
+ token-generator:
+ type: jwt
+ secret-key-file: classpath:local/secrets/local.private.key
+