Skip to content
This repository was archived by the owner on Nov 23, 2025. It is now read-only.
Merged
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
7 changes: 3 additions & 4 deletions .github/workflows/buildtest.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
name: Build and Test Admin Service

on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
- main
- dev
- devOps

jobs:
build-test:
Expand Down
27 changes: 27 additions & 0 deletions admin-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Comment on lines +50 to +52
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

[nitpick] The spring-boot-starter-oauth2-resource-server dependency is added but not used in the configuration. The JWT authentication is implemented manually via JwtAuthenticationFilter using the jjwt library. Consider either using the OAuth2 resource server configuration properly or removing this unused dependency to avoid confusion and reduce the application size.

Suggested change
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Copilot uses AI. Check for mistakes.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
Expand All @@ -54,6 +58,29 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<!-- JWT dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.techtorque.admin_service.config;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

@Value("${jwt.secret:mysecretkey}")
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The hardcoded default JWT secret 'mysecretkey' in the filter differs from the default in application.properties. Both should use the same default value, or better yet, the filter should reference the same property key without a default.

Suggested change
@Value("${jwt.secret:mysecretkey}")
@Value("${jwt.secret}")

Copilot uses AI. Check for mistakes.
private String jwtSecret;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

String authHeader = request.getHeader("Authorization");

if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);

try {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));

Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();

String username = claims.getSubject();
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) claims.get("roles");

if (username != null && roles != null) {
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> {
String roleUpper = role.trim().toUpperCase();
// Treat SUPER_ADMIN as ADMIN for authorization purposes
if ("SUPER_ADMIN".equals(roleUpper)) {
// Add both SUPER_ADMIN and ADMIN roles
return Arrays.asList(
new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"),
new SimpleGrantedAuthority("ROLE_ADMIN")
);
}
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + roleUpper));
})
.flatMap(List::stream)
.collect(Collectors.toList());

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);

SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.warn("JWT token validation failed: " + e.getMessage());
}
}

filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.techtorque.admin_service.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
Expand All @@ -12,8 +13,11 @@
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;

// A more comprehensive whitelist for Swagger/OpenAPI, based on the auth-service config.
private static final String[] SWAGGER_WHITELIST = {
"/v3/api-docs/**",
Expand All @@ -24,6 +28,11 @@ public class SecurityConfig {
"/api-docs/**"
};

// Public endpoints accessible by all authenticated users (including CUSTOMER role)
private static final String[] PUBLIC_ENDPOINTS = {
"/public/**"
};

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
Expand All @@ -42,12 +51,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Permit all requests to the Swagger UI and API docs paths
.requestMatchers(SWAGGER_WHITELIST).permitAll()

// Allow authenticated users to access public endpoints
.requestMatchers(PUBLIC_ENDPOINTS).authenticated()

// All other requests must be authenticated
.anyRequest().authenticated()
)

// Add our custom filter to read headers from the Gateway
.addFilterBefore(new GatewayHeaderFilter(), UsernamePasswordAuthenticationFilter.class);
// Add JWT filter first (for direct service-to-service calls with JWT)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

// Add our custom filter to read headers from the Gateway (for gateway-routed calls)
.addFilterAfter(new GatewayHeaderFilter(), JwtAuthenticationFilter.class);

return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import reactor.core.publisher.Mono;
import jakarta.servlet.http.HttpServletRequest;

/**
* Configuration for WebClient to communicate with other microservices
Expand All @@ -31,12 +36,35 @@ public class WebClientConfig {
@Value("${services.vehicle.url:http://localhost:8082}")
private String vehicleServiceUrl;

/**
* Exchange filter function to propagate JWT token from incoming request to outgoing WebClient calls
*/
private ExchangeFilterFunction jwtTokenPropagationFilter() {
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return Mono.just(
org.springframework.web.reactive.function.client.ClientRequest
.from(clientRequest)
.header(HttpHeaders.AUTHORIZATION, authHeader)
.build()
);
}
}
return Mono.just(clientRequest);
Comment on lines +44 to +57
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

[nitpick] The JWT token propagation filter silently fails and returns the original request if RequestContextHolder.getRequestAttributes() returns null or if there's no Authorization header. This will occur during async operations or when called outside the HTTP request thread. Consider logging a debug message when the token cannot be propagated to help troubleshoot authentication issues in service-to-service calls.

Copilot uses AI. Check for mistakes.
});
}

@Bean(name = "authServiceWebClient")
public WebClient authServiceWebClient(WebClient.Builder webClientBuilder) {
return webClientBuilder
.baseUrl(authServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(jwtTokenPropagationFilter())
.build();
}

Expand All @@ -46,6 +74,7 @@ public WebClient paymentServiceWebClient(WebClient.Builder webClientBuilder) {
.baseUrl(paymentServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(jwtTokenPropagationFilter())
.build();
}

Expand All @@ -55,6 +84,7 @@ public WebClient appointmentServiceWebClient(WebClient.Builder webClientBuilder)
.baseUrl(appointmentServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(jwtTokenPropagationFilter())
.build();
}

Expand All @@ -64,6 +94,7 @@ public WebClient projectServiceWebClient(WebClient.Builder webClientBuilder) {
.baseUrl(projectServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(jwtTokenPropagationFilter())
.build();
}

Expand All @@ -73,6 +104,7 @@ public WebClient timeLoggingServiceWebClient(WebClient.Builder webClientBuilder)
.baseUrl(timeLoggingServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(jwtTokenPropagationFilter())
.build();
}

Expand All @@ -82,6 +114,7 @@ public WebClient vehicleServiceWebClient(WebClient.Builder webClientBuilder) {
.baseUrl(vehicleServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(jwtTokenPropagationFilter())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.techtorque.admin_service.controller;

import com.techtorque.admin_service.dto.response.ServiceTypeResponse;
import com.techtorque.admin_service.service.AdminServiceConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* Public controller for service types - accessible by all authenticated users and other microservices
* This allows customers and other services to read active service types without admin privileges
*/
@RestController
@RequestMapping("/public/service-types")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Public Service Types", description = "Public API for accessing service types")
public class PublicServiceTypeController {

private final AdminServiceConfigService adminServiceConfigService;

@GetMapping
@Operation(summary = "Get all active service types", description = "Retrieve all active service types available to customers")
public ResponseEntity<List<ServiceTypeResponse>> getActiveServiceTypes() {
log.info("Public request: Fetching all active service types");
List<ServiceTypeResponse> serviceTypes = adminServiceConfigService.getAllServiceTypes(true); // activeOnly = true
log.info("Retrieved {} active service types for public access", serviceTypes.size());
return ResponseEntity.ok(serviceTypes);
}

@GetMapping("/{id}")
@Operation(summary = "Get service type by ID", description = "Retrieve a specific service type by its ID")
public ResponseEntity<ServiceTypeResponse> getServiceTypeById(@PathVariable String id) {
log.info("Public request: Fetching service type with ID: {}", id);
ServiceTypeResponse serviceType = adminServiceConfigService.getServiceTypeById(id);
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The getServiceTypeById endpoint doesn't filter by active status, which means it can return inactive service types. This is inconsistent with the documented behavior that this public API should only expose active service types. Consider either filtering to only return active service types or updating the documentation to clarify this behavior.

Suggested change
ServiceTypeResponse serviceType = adminServiceConfigService.getServiceTypeById(id);
ServiceTypeResponse serviceType = adminServiceConfigService.getServiceTypeById(id);
if (serviceType == null || (serviceType.getActive() != null && !serviceType.getActive())) {
log.warn("Service type with ID {} not found or not active for public access", id);
return ResponseEntity.notFound().build();
}

Copilot uses AI. Check for mistakes.
return ResponseEntity.ok(serviceType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,24 @@ public class UpdateUserRequest {
@Pattern(regexp = "ADMIN|EMPLOYEE|CUSTOMER", message = "Role must be ADMIN, EMPLOYEE, or CUSTOMER")
private String role;

private List<String> roles;
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The roles field (line 27) has no validation, while the singular role field has a pattern constraint. If roles can contain values other than "ADMIN", "EMPLOYEE", or "CUSTOMER", this could lead to invalid role assignments. Consider adding validation to ensure all items in the roles list match the allowed values.

Copilot uses AI. Check for mistakes.

private Boolean active;

// Alternative field name for activation status (frontend compatibility)
private Boolean enabled;

@Size(max = 20, message = "Maximum 20 permissions allowed")
private List<String> permissions;

@Size(max = 100)
private String department;

/**
* Get the activation status, checking both 'active' and 'enabled' fields
*/
public Boolean getActivationStatus() {
// Prioritize 'enabled' if set, otherwise use 'active'
return enabled != null ? enabled : active;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ public class ServiceTypeResponse {
private String name;
private String description;
private String category;
private BigDecimal price;
private Integer durationMinutes;
private BigDecimal basePriceLKR; // Changed from 'price' to match frontend
private Integer estimatedDurationMinutes; // Changed from 'durationMinutes' to match frontend
Comment on lines +23 to +24
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The response DTO field names were changed to basePriceLKR and estimatedDurationMinutes, but the request DTOs (CreateServiceTypeRequest and UpdateServiceTypeRequest) still use the old field names price and durationMinutes. This creates an inconsistency in the API where request and response field names don't match, which can confuse API consumers. Consider updating the request DTOs to use the same field names or providing clear documentation about this discrepancy.

Copilot uses AI. Check for mistakes.
private Boolean active;
private Boolean requiresApproval;
private Integer dailyCapacity;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,8 @@ public void deleteServiceType(String id, String deletedBy) {
ServiceType serviceType = serviceTypeRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Service type not found: " + id));

// Soft delete
serviceType.setActive(false);
serviceTypeRepository.save(serviceType);
// Hard delete - actually remove from database
serviceTypeRepository.delete(serviceType);

log.info("Service type deleted successfully: {}", id);
Comment on lines +124 to 127
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

Changing from soft delete to hard delete is a breaking change that could cause data integrity issues. If there are any appointments, projects, or other entities referencing this service type, the hard delete will fail with a foreign key constraint violation or leave orphaned references. Consider either: (1) keeping soft delete, (2) adding cascade delete logic, or (3) validating that no references exist before deletion.

Suggested change
// Hard delete - actually remove from database
serviceTypeRepository.delete(serviceType);
log.info("Service type deleted successfully: {}", id);
// Soft delete - mark as inactive instead of removing from database
serviceType.setActive(false);
serviceType.setUpdatedAt(java.time.LocalDateTime.now());
serviceTypeRepository.save(serviceType);
log.info("Service type soft-deleted (marked inactive) successfully: {}", id);

Copilot uses AI. Check for mistakes.
}
Expand All @@ -134,8 +133,8 @@ private ServiceTypeResponse convertToResponse(ServiceType serviceType) {
.name(serviceType.getName())
.description(serviceType.getDescription())
.category(serviceType.getCategory())
.price(serviceType.getPrice())
.durationMinutes(serviceType.getDefaultDurationMinutes())
.basePriceLKR(serviceType.getPrice())
.estimatedDurationMinutes(serviceType.getDefaultDurationMinutes())
.active(serviceType.getActive())
.requiresApproval(serviceType.getRequiresApproval())
.dailyCapacity(serviceType.getDailyCapacity())
Expand Down
Loading