Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.gradle
**/build
**/target
node_modules
.idea
.vscode
*.log
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=

DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASSWORD=

KEYCLOAK_REALM=
KEYCLOAK_REALM_USER=
KEYCLOAK_ADMIN_USERNAME=
KEYCLOAK_ADMIN_PASSWORD=
KEYCLOAK_LOGIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_DB_PASSWORD=

RABBITMQ_USERNAME=
RABBITMQ_PASSWORD=
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ jobs:
RABBITMQ_USERNAME: ${{ secrets.RABBITMQ_USERNAME }}
RABBITMQ_PASSWORD: ${{ secrets.RABBITMQ_PASSWORD }}

KEYCLOAK_DB_PASSWORD: ${{ secrets.KEYCLOAK_DB_PASSWORD }}
KEYCLOAK_ADMIN_USERNAME: ${{ secrets.KEYCLOAK_ADMIN_USERNAME }}
KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }}
KEYCLOAK_REALM: ${{ secrets.KEYCLOAK_REALM }}
KEYCLOAK_REALM_USER: ${{ secrets.KEYCLOAK_REALM_USER }}
KEYCLOAK_ADMIN_CLIENT_ID: ${{ secrets.KEYCLOAK_ADMIN_CLIENT_ID }}
KEYCLOAK_LOGIN_CLIENT_ID: ${{ secrets.KEYCLOAK_LOGIN_CLIENT_ID }}

SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
Comment on lines +19 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

시크릿을 job 전역 env에 두지 말고 필요한 step 범위로 축소하세요.

Line 19-28의 신규 시크릿이 job 전체(step 전부)에 노출됩니다. 외부 액션까지 포함해 노출면이 넓어져 최소 권한 원칙에 어긋납니다. Build and start services step의 env로 이동해 범위를 줄이는 게 안전합니다.

권장 수정안 (step 범위로 env 축소)
 jobs:
   docker-compose-test:
     runs-on: ubuntu-latest
     timeout-minutes: 30

     env:
       POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
       POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
       POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
       RABBITMQ_USERNAME: ${{ secrets.RABBITMQ_USERNAME }}
       RABBITMQ_PASSWORD: ${{ secrets.RABBITMQ_PASSWORD }}
-      KEYCLOAK_DB_PASSWORD: ${{ secrets.KEYCLOAK_DB_PASSWORD }}
-      KEYCLOAK_ADMIN_USERNAME: ${{ secrets.KEYCLOAK_ADMIN_USERNAME }}
-      KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }}
-      KEYCLOAK_REALM: ${{ secrets.KEYCLOAK_REALM }}
-      KEYCLOAK_REALM_USER: ${{ secrets.KEYCLOAK_REALM_USER }}
-      KEYCLOAK_ADMIN_CLIENT_ID: ${{ secrets.KEYCLOAK_ADMIN_CLIENT_ID }}
-      KEYCLOAK_LOGIN_CLIENT_ID: ${{ secrets.KEYCLOAK_LOGIN_CLIENT_ID }}
-      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
-      GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

     steps:
       - name: Checkout
         uses: actions/checkout@v4
@@
       - name: Build and start services
+        env:
+          KEYCLOAK_DB_PASSWORD: ${{ secrets.KEYCLOAK_DB_PASSWORD }}
+          KEYCLOAK_ADMIN_USERNAME: ${{ secrets.KEYCLOAK_ADMIN_USERNAME }}
+          KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }}
+          KEYCLOAK_REALM: ${{ secrets.KEYCLOAK_REALM }}
+          KEYCLOAK_REALM_USER: ${{ secrets.KEYCLOAK_REALM_USER }}
+          KEYCLOAK_ADMIN_CLIENT_ID: ${{ secrets.KEYCLOAK_ADMIN_CLIENT_ID }}
+          KEYCLOAK_LOGIN_CLIENT_ID: ${{ secrets.KEYCLOAK_LOGIN_CLIENT_ID }}
+          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
         run: docker compose up -d --build --wait --wait-timeout 500
         timeout-minutes: 25
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 19 - 28, The job-level env block
currently exposes secrets (KEYCLOAK_DB_PASSWORD, KEYCLOAK_ADMIN_USERNAME,
KEYCLOAK_ADMIN_PASSWORD, KEYCLOAK_REALM, KEYCLOAK_REALM_USER,
KEYCLOAK_ADMIN_CLIENT_ID, KEYCLOAK_LOGIN_CLIENT_ID, SLACK_BOT_TOKEN,
GEMINI_API_KEY); move these variables into the specific step-level env for the
"Build and start services" step so only that step (not the entire job or other
actions) can access them, by removing them from the job env and adding them
under the env key inside the "Build and start services" step.



steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -24,9 +36,9 @@ jobs:
uses: docker/setup-buildx-action@v3

- name: Build and start services
run: docker compose up -d --build --wait --wait-timeout 180
run: docker compose up -d --build --wait --wait-timeout 500
timeout-minutes: 25

- name: Tear down
if: always()
run: docker compose down -v
run: docker compose down -v
29 changes: 27 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,16 @@ services:
- "8080"
environment:
<<: *service-env
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
KEYCLOAK_REALM_USER: ${KEYCLOAK_REALM_USER}
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KEYCLOAK_ADMIN_CLIENT_ID: ${KEYCLOAK_ADMIN_CLIENT_ID}
KEYCLOAK_LOGIN_CLIENT_ID: ${KEYCLOAK_LOGIN_CLIENT_ID}
depends_on:
<<: *service-depends-on
keycloak :
condition: service_healthy

rabbitmq:
image: rabbitmq:3-management
Expand Down Expand Up @@ -207,6 +215,12 @@ services:
- "5433:5432"
volumes:
- shipflow_keycloak_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s

keycloak:
build:
Expand All @@ -216,8 +230,12 @@ services:
container_name: shipflow-keycloak
restart: unless-stopped
environment:
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}
realm: ${KEYCLOAK_REALM}
user-realm: ${KEYCLOAK_REALM_USER}
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USERNAME}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
admin-client-id: ${KEYCLOAK_ADMIN_CLIENT_ID}
login-client-id: ${KEYCLOAK_LOGIN_CLIENT_ID}
Comment thread
zlonce marked this conversation as resolved.
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-postgres:5432/keycloak
KC_DB_USERNAME: keycloak
Expand All @@ -227,7 +245,14 @@ services:
ports:
- "9001:8080"
depends_on:
- keycloak-postgres
keycloak-postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "bash -c '</dev/tcp/127.0.0.1/8080'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 180s

volumes:
shipflow_postgres_data:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,33 @@

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.springframework.http.HttpMethod.*;

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {

private static final String[] WHITELIST = {
Expand All @@ -30,7 +48,6 @@ public class SecurityConfig {
public static final String AILOG = "/api/ai/**";
public static final String SLACK = "/api/slack/**";


@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
Expand Down Expand Up @@ -77,9 +94,36 @@ public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
)

.oauth2ResourceServer(oauth -> oauth
.jwt(Customizer.withDefaults())
.jwt(jwt -> jwt.jwtAuthenticationConverter(grantedAuthoritiesExtractor()))
)

.build();
}

@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> grantedAuthoritiesExtractor() {
Converter<Jwt, Collection<GrantedAuthority>> delegate = jwt -> {
JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter();
Collection<GrantedAuthority> defaultAuthorities = defaultConverter.convert(jwt);

Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
List<GrantedAuthority> realmRoles = List.of();

if (realmAccess != null && realmAccess.get("roles") instanceof List<?> roles) {
realmRoles = roles.stream()
.map(Object::toString)
.map(role -> role.startsWith("ROLE_") ? role : "ROLE_" + role)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}

return Stream.concat(defaultAuthorities.stream(), realmRoles.stream())
.collect(Collectors.toSet());
};

return new ReactiveJwtAuthenticationConverterAdapter(jwt -> {
Collection<GrantedAuthority> authorities = delegate.convert(jwt);
return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject());
});
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.shipflow.gatewayserver.config;

import com.shipflow.gatewayserver.exception.BusinessException;
import com.shipflow.gatewayserver.exception.GateErrorCode;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.server.reactive.ServerHttpRequest;
Expand All @@ -13,8 +15,7 @@
import java.util.List;
import java.util.Map;

import com.shipflow.gatewayserver.exception.BusinessException;
import com.shipflow.gatewayserver.exception.GateErrorCode;
import static reactor.netty.http.HttpConnectionLiveness.log;

@Component
public class UserHeaderFilter implements GlobalFilter {
Expand All @@ -25,50 +26,56 @@ public class UserHeaderFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

// 모든 요청에서 먼저 헤더 제거 (핵심!)
ServerWebExchange sanitizedExchange = exchange.mutate()
.request(exchange.getRequest().mutate()
.headers(headers -> {
headers.remove(USER_ID_HEADER);
headers.remove(USER_ROLE_HEADER);
})
.build())
.build();

return sanitizedExchange.getPrincipal()
.ofType(Authentication.class)
.flatMap(auth -> {

if (auth instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
log.info("[UserHeaderFilter] entered. path={}, method={}",
exchange.getRequest().getURI().getPath(),
exchange.getRequest().getMethod());

String userId = jwt.getSubject();
String role = extractRole(jwt);

if (userId == null || userId.isBlank() || role == null || role.isBlank()) {
return Mono.error(new BusinessException(GateErrorCode.MISSING_ROLES));
}

// JWT 값으로만 재주입
ServerHttpRequest mutated = sanitizedExchange.getRequest().mutate()
ServerWebExchange sanitizedExchange = exchange.mutate()
.request(exchange.getRequest().mutate()
.headers(headers -> {
headers.add(USER_ID_HEADER, userId);
headers.add(USER_ROLE_HEADER, role);
headers.remove(USER_ID_HEADER);
headers.remove(USER_ROLE_HEADER);
})
.build();
.build())
.build();

return chain.filter(
sanitizedExchange.mutate().request(mutated).build()
);
}
return sanitizedExchange.getPrincipal()
.doOnNext(p -> log.info("[UserHeaderFilter] principal={}", p.getClass().getName()))
.switchIfEmpty(Mono.fromRunnable(() ->
log.warn("[UserHeaderFilter] principal is empty")
))
.ofType(Authentication.class)
.doOnNext(auth -> log.info("[UserHeaderFilter] auth class={}", auth.getClass().getName()))
.flatMap(auth -> {
if (auth instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();

String userId = jwt.getSubject();
String role = extractRole(jwt);

if (userId == null || userId.isBlank() || role == null || role.isBlank()) {
return Mono.error(new BusinessException(GateErrorCode.MISSING_ROLES));
}

ServerHttpRequest mutated = sanitizedExchange.getRequest().mutate()
.headers(headers -> {
headers.add(USER_ID_HEADER, userId);
headers.add(USER_ROLE_HEADER, role);
})
.build();

return chain.filter(
sanitizedExchange.mutate().request(mutated).build()
);
}

return chain.filter(sanitizedExchange);
})
.switchIfEmpty(chain.filter(sanitizedExchange));
log.warn("[UserHeaderFilter] Authentication exists but not JwtAuthenticationToken");
return chain.filter(sanitizedExchange);
})
.switchIfEmpty(chain.filter(sanitizedExchange));
}


private String extractRole(Jwt jwt) { //role 추출
private String extractRole(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
throw new BusinessException(GateErrorCode.MISSING_REALM_ACCESS);
Expand Down
2 changes: 1 addition & 1 deletion gateway-server/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ spring:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9001/realms/shipflow
issuer-uri: http://keycloak:8080/realms/shipflow
Comment thread
zlonce marked this conversation as resolved.

eureka:
client:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ public User(UUID id, String username, String name, String slackId) {
this.name = name;
this.slackId = slackId;
this.status = UserStatus.PENDING;
this.createdAt = LocalDateTime.now();

LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.createdBy = id;
this.updatedAt = now;
this.updatedBy = id;
Comment on lines +61 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# AuditorAware 구현체 확인
echo "=== AuditorAware 구현 검색 ==="
rg -n "AuditorAware" --type java

# JpaAuditingConfig 상세 확인
echo "=== JpaAuditingConfig 내용 ==="
fd -t f "JpaAuditingConfig.java" --exec cat {}

Repository: Ship-Flow/ship-flow

Length of output: 3471


🏁 Script executed:

#!/bin/bash
# Find BaseEntity in user-service
echo "=== Searching for BaseEntity in user-service ==="
fd -t f "BaseEntity.java" user-service --exec cat {}

# Look at User.java around lines 61-66 with more context
echo "=== User.java constructor context (lines 50-85) ==="
sed -n '50,85p' user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java

Repository: Ship-Flow/ship-flow

Length of output: 1138


🏁 Script executed:

#!/bin/bash
# Search for BaseEntity across entire repository
echo "=== Searching for BaseEntity class ==="
fd -t f -i "baseentity.java" --exec echo "File: {}" \; --exec cat {} \;

# Read User.java class declaration and field definitions (lines 1-60)
echo "=== User.java class declaration and fields (lines 1-60) ==="
head -60 user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java

Repository: Ship-Flow/ship-flow

Length of output: 4444


JPA 감사 자동화와 수동 설정의 충돌 문제

User 클래스는 common 모듈의 BaseEntity를 확장하며, BaseEntity에는 @EntityListeners(AuditingEntityListener.class)@CreatedDate, @CreatedBy, @LastModifiedDate, @LastModifiedBy 어노테이션이 설정되어 있습니다. 생성자에서 수동으로 설정한 createdAt, createdBy, updatedAt, updatedBy 값은 엔티티 퍼시스트 시점에 JPA 감사 리스너에 의해 덮어씌워집니다.

더욱이, user-serviceJpaAuditingConfig@EnableJpaAuditing만 활성화하고 AuditorAware 빈을 구현하지 않았습니다. 다른 서비스들(hub-service, product-service 등)은 AuditorAware<UUID> 빈을 제공하지만, user-service는 이를 구현하지 않아 createdByupdatedBy 필드가 올바르게 설정되지 않을 수 있습니다. 생성자에서의 수동 설정은 JPA 감사에 의해 덮어씌워지므로 실제 효과가 없습니다.

생성자에서의 수동 설정을 제거하고, user-serviceAuditorAware 구현을 추가하거나, 또는 현재 설계 의도를 명확히 하여 JPA 감사 설정을 조정해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java`
around lines 61 - 66, The User constructor is manually setting auditing fields
(createdAt, createdBy, updatedAt, updatedBy) which conflicts with JPA auditing
in BaseEntity; remove the manual assignments in User (the lines assigning
LocalDateTime now and setting those four fields) and instead provide an
AuditorAware<UUID> bean in this service (e.g., implement and register an
AuditorAware that returns the current user id in your JpaAuditingConfig or a
dedicated config class) so `@CreatedBy/`@LastModifiedBy on BaseEntity are
populated correctly; ensure `@EnableJpaAuditing` remains and that BaseEntity’s
`@CreatedDate/`@CreatedBy/@LastModifiedDate/@LastModifiedBy annotations are used
as the single source of truth.

}

public User(UUID id, String username, String name, String slackId, UUID hubId, UUID companyId) {
Expand All @@ -68,6 +72,12 @@ public User(UUID id, String username, String name, String slackId, UUID hubId, U
this.companyId = companyId;
}

public User(UUID id, String username, String name, String slackId, UserRole role, UserStatus status) {
this(id, username, name, slackId);
this.status = status;
this.role = role;
}

public void approve(UserRole role){ //승인
if (this.status != UserStatus.PENDING) {
throw new BusinessException(UserErrorCode.INVALID_USER_STATUS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import java.util.UUID;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "shipment-service")
public interface ShipmentFeignClient {
@PatchMapping("/internal/shipments/{userId}")
@DeleteMapping("/internal/shipment-managers/users/{userId}")
ClientApiResponse<Void> patchManager(@PathVariable UUID userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.shipflow.userservice.infrastructure.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti

return http.build();
}

@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation("http://localhost:9001/realms/shipflow");
}
}
Loading