diff --git a/backend/api-gateway/.gitignore b/backend/api-gateway/.gitignore
new file mode 100644
index 0000000..6255ee4
--- /dev/null
+++ b/backend/api-gateway/.gitignore
@@ -0,0 +1,13 @@
+target/
+*.class
+*.jar
+!.mvn/wrapper/maven-wrapper.jar
+.idea/
+*.iml
+.vscode/
+.DS_Store
+*.log
+HELP.md
+.mvn/
+mvnw
+mvnw.cmd
diff --git a/backend/api-gateway/Dockerfile b/backend/api-gateway/Dockerfile
new file mode 100644
index 0000000..dd4a353
--- /dev/null
+++ b/backend/api-gateway/Dockerfile
@@ -0,0 +1,18 @@
+# ---- Build stage ----
+FROM maven:3.9-eclipse-temurin-17 AS build
+WORKDIR /app
+
+COPY common-lib ./common-lib
+RUN cd common-lib && mvn -B clean install -DskipTests
+
+COPY api-gateway/pom.xml ./api-gateway/pom.xml
+RUN cd api-gateway && mvn -B dependency:go-offline
+COPY api-gateway/src ./api-gateway/src
+RUN cd api-gateway && mvn -B clean package -DskipTests
+
+# ---- Run stage ----
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY --from=build /app/api-gateway/target/*.jar app.jar
+EXPOSE 9000
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/backend/api-gateway/pom.xml b/backend/api-gateway/pom.xml
new file mode 100644
index 0000000..cb91828
--- /dev/null
+++ b/backend/api-gateway/pom.xml
@@ -0,0 +1,69 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.4
+
+
+
+ com.example
+ api-gateway
+ 1.0.0
+ api-gateway
+ Single entry point for the Student Management System: routing, JWT validation, and CORS
+
+
+ 17
+ 2023.0.3
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+ com.example
+ common-lib
+ 1.0.0
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java b/backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java
new file mode 100644
index 0000000..fbe1c81
--- /dev/null
+++ b/backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java
@@ -0,0 +1,18 @@
+package com.example.apigateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Single entry point for all clients (the React frontend, Postman, etc).
+ * Routes requests to the correct downstream service and rejects requests
+ * with a missing/invalid/expired JWT before they ever reach a backend
+ * service - defense in depth alongside each service's own JWT filter.
+ */
+@SpringBootApplication
+public class ApiGatewayApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ApiGatewayApplication.class, args);
+ }
+}
diff --git a/backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java b/backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java
new file mode 100644
index 0000000..4a0611d
--- /dev/null
+++ b/backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java
@@ -0,0 +1,82 @@
+package com.example.apigateway.filter;
+
+import com.example.common.security.JwtTokenValidator;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * Runs before every request is routed. Public endpoints (register, login,
+ * refresh, actuator health) pass straight through; everything else must
+ * carry a syntactically valid, unexpired Bearer token or the gateway
+ * rejects it with 401 before it ever reaches a backend service.
+ *
+ * This is deliberately a coarse check (signature + expiry only) - the
+ * gateway doesn't know about roles/ownership rules for each resource, so
+ * fine-grained authorization still happens in the owning service, which
+ * independently re-validates the same JWT.
+ */
+@Component
+public class JwtValidationGlobalFilter implements GlobalFilter, Ordered {
+
+ private static final List PUBLIC_PATHS = List.of(
+ "/api/v1/auth/register",
+ "/api/v1/auth/login",
+ "/api/v1/auth/refresh",
+ "/actuator/health"
+ );
+
+ private final JwtTokenValidator jwtTokenValidator;
+
+ public JwtValidationGlobalFilter(@Value("${jwt.secret}") String jwtSecret) {
+ this.jwtTokenValidator = new JwtTokenValidator(jwtSecret);
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ ServerHttpRequest request = exchange.getRequest();
+ String path = request.getURI().getPath();
+
+ if (isPublic(path)) {
+ return chain.filter(exchange);
+ }
+
+ String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ return reject(exchange, "Missing or malformed Authorization header");
+ }
+
+ String token = authHeader.substring(7);
+ if (!jwtTokenValidator.isValid(token)) {
+ return reject(exchange, "Invalid or expired token");
+ }
+
+ return chain.filter(exchange);
+ }
+
+ private boolean isPublic(String path) {
+ return PUBLIC_PATHS.stream().anyMatch(path::startsWith);
+ }
+
+ private Mono reject(ServerWebExchange exchange, String message) {
+ exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
+ exchange.getResponse().getHeaders().add("Content-Type", "application/json");
+ byte[] body = ("{\"success\":false,\"message\":\"" + message + "\"}").getBytes();
+ return exchange.getResponse().writeWith(
+ Mono.just(exchange.getResponse().bufferFactory().wrap(body)));
+ }
+
+ @Override
+ public int getOrder() {
+ return -1;
+ }
+}
diff --git a/backend/api-gateway/src/main/resources/application.yml b/backend/api-gateway/src/main/resources/application.yml
new file mode 100644
index 0000000..fd19482
--- /dev/null
+++ b/backend/api-gateway/src/main/resources/application.yml
@@ -0,0 +1,59 @@
+server:
+ port: 9000
+
+spring:
+ application:
+ name: api-gateway
+ cloud:
+ gateway:
+ routes:
+ - id: auth-service
+ uri: ${AUTH_SERVICE_URL:http://localhost:8080}
+ predicates:
+ - Path=/api/v1/auth/**,/api/v1/admin/users/**
+
+ - id: student-service
+ uri: ${STUDENT_SERVICE_URL:http://localhost:8081}
+ predicates:
+ - Path=/api/v1/students/**
+
+ - id: enrollment-service-courses
+ uri: ${ENROLLMENT_SERVICE_URL:http://localhost:8082}
+ predicates:
+ - Path=/api/v1/courses/**
+
+ - id: enrollment-service-enrollments
+ uri: ${ENROLLMENT_SERVICE_URL:http://localhost:8082}
+ predicates:
+ - Path=/api/v1/enrollments/**
+
+ globalcors:
+ cors-configurations:
+ '[/**]':
+ allowedOriginPatterns: "*"
+ allowedMethods:
+ - GET
+ - POST
+ - PUT
+ - PATCH
+ - DELETE
+ - OPTIONS
+ allowedHeaders: "*"
+ allowCredentials: true
+
+# ---- JWT (must match auth-service's jwt.secret - the gateway validates
+# signature/expiry only; fine-grained ownership checks still happen
+# in each downstream service, which also re-validates independently). ----
+jwt:
+ secret: ${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,gateway
+
+logging:
+ level:
+ com.example.apigateway: DEBUG
+ org.springframework.cloud.gateway: INFO
diff --git a/backend/common-lib/pom.xml b/backend/common-lib/pom.xml
index c0121c5..6ff1c36 100644
--- a/backend/common-lib/pom.xml
+++ b/backend/common-lib/pom.xml
@@ -18,6 +18,8 @@
3.0.2
2.17.2
1.18.34
+ 0.12.6
+ 6.3.3
@@ -37,6 +39,36 @@
${lombok.version}
provided
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
+
+
+
+ org.springframework.security
+ spring-security-core
+ ${spring-security.version}
+ provided
+
diff --git a/backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java b/backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java
new file mode 100644
index 0000000..5886397
--- /dev/null
+++ b/backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java
@@ -0,0 +1,36 @@
+package com.example.common.security;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * The authenticated caller, as decoded from a JWT issued by auth-service.
+ * This is what SecurityContextHolder's Authentication#getPrincipal()
+ * returns in every resource service (student-service, enrollment-service,
+ * course-service, grade-service) once JwtAuthenticationFilter runs.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class JwtPrincipal {
+ private Long userId;
+ private String email;
+ private String role;
+ private Long studentId;
+
+ public boolean isAdmin() {
+ return "ADMIN".equalsIgnoreCase(role);
+ }
+
+ public boolean isStudent() {
+ return "STUDENT".equalsIgnoreCase(role);
+ }
+
+ /** True if this principal is a STUDENT whose linked profile matches the given id. */
+ public boolean ownsStudentId(Long id) {
+ return isStudent() && studentId != null && studentId.equals(id);
+ }
+}
diff --git a/backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java b/backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java
new file mode 100644
index 0000000..27007d6
--- /dev/null
+++ b/backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java
@@ -0,0 +1,55 @@
+package com.example.common.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+
+import javax.crypto.SecretKey;
+import java.util.Date;
+
+/**
+ * Validates and decodes JWTs issued by auth-service. Every resource
+ * service constructs one of these (as a @Bean, seeded with the same
+ * shared jwt.secret auth-service uses) instead of re-implementing JWT
+ * parsing locally.
+ *
+ * This class is intentionally framework-agnostic (no servlet/reactive
+ * dependency) so it works from both a servlet-stack filter (student-service,
+ * enrollment-service) and a WebFlux GlobalFilter (api-gateway).
+ */
+public class JwtTokenValidator {
+
+ private final SecretKey signingKey;
+
+ public JwtTokenValidator(String secret) {
+ this.signingKey = Keys.hmacShaKeyFor(secret.getBytes());
+ }
+
+ public boolean isValid(String token) {
+ try {
+ Claims claims = extractAllClaims(token);
+ return claims.getExpiration().after(new Date());
+ } catch (Exception ex) {
+ return false;
+ }
+ }
+
+ public JwtPrincipal extractPrincipal(String token) {
+ Claims claims = extractAllClaims(token);
+ Long studentId = claims.get("studentId", Long.class);
+ return JwtPrincipal.builder()
+ .userId(claims.get("userId", Long.class))
+ .email(claims.getSubject())
+ .role(claims.get("role", String.class))
+ .studentId(studentId)
+ .build();
+ }
+
+ private Claims extractAllClaims(String token) {
+ return Jwts.parser()
+ .verifyWith(signingKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+}
diff --git a/backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java b/backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java
new file mode 100644
index 0000000..a955125
--- /dev/null
+++ b/backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java
@@ -0,0 +1,19 @@
+package com.example.common.security;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/** Convenience accessor for the current authenticated JwtPrincipal. */
+public final class SecurityUtils {
+
+ private SecurityUtils() {
+ }
+
+ public static JwtPrincipal currentUser() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !(auth.getPrincipal() instanceof JwtPrincipal principal)) {
+ return null;
+ }
+ return principal;
+ }
+}
diff --git a/backend/enrollment-service/Dockerfile b/backend/enrollment-service/Dockerfile
index 5071364..b734bb2 100644
--- a/backend/enrollment-service/Dockerfile
+++ b/backend/enrollment-service/Dockerfile
@@ -1,14 +1,18 @@
# ---- Build stage ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
-COPY pom.xml .
-RUN mvn -B dependency:go-offline
-COPY src ./src
-RUN mvn -B clean package -DskipTests
+
+COPY common-lib ./common-lib
+RUN cd common-lib && mvn -B clean install -DskipTests
+
+COPY enrollment-service/pom.xml ./enrollment-service/pom.xml
+RUN cd enrollment-service && mvn -B dependency:go-offline
+COPY enrollment-service/src ./enrollment-service/src
+RUN cd enrollment-service && mvn -B clean package -DskipTests
# ---- Run stage ----
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
-COPY --from=build /app/target/*.jar app.jar
+COPY --from=build /app/enrollment-service/target/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/backend/enrollment-service/pom.xml b/backend/enrollment-service/pom.xml
index c5d0c4d..77c2acb 100644
--- a/backend/enrollment-service/pom.xml
+++ b/backend/enrollment-service/pom.xml
@@ -22,6 +22,15 @@
+
+ com.example
+ common-lib
+ 1.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
org.springframework.boot
spring-boot-starter-web
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java
index ecfb565..dd74c8b 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java
@@ -4,10 +4,10 @@
import com.example.enrollmentservice.exception.StudentServiceUnavailableException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
-import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Optional;
@@ -16,6 +16,12 @@
* Encapsulates all HTTP calls from enrollment-service to student-service.
* Keeping this in a single client class means the rest of the codebase
* never talks HTTP directly - it only knows about StudentDTO.
+ *
+ * Every method takes the caller's original Bearer token and forwards it
+ * downstream ("pass-through auth"), rather than enrollment-service having
+ * its own service-account credentials. That way student-service applies
+ * the SAME ownership rule it would for a direct call: a STUDENT token can
+ * only read their own profile, an ADMIN token can read anyone's.
*/
@Component
@RequiredArgsConstructor
@@ -25,13 +31,14 @@ public class StudentClient {
private final WebClient studentServiceWebClient;
/**
- * Fetches the full student profile. Returns empty if the student
- * does not exist (404 from student-service).
+ * Fetches the full student profile using the caller's own bearer token.
+ * Returns empty if the student does not exist (404 from student-service).
*/
- public Optional getStudentById(Long studentId) {
+ public Optional getStudentById(Long studentId, String bearerToken) {
try {
StudentDTO student = studentServiceWebClient.get()
.uri("/api/v1/students/{id}", studentId)
+ .header(HttpHeaders.AUTHORIZATION, bearerToken)
.retrieve()
.bodyToMono(StudentDTO.class)
.timeout(Duration.ofSeconds(5))
@@ -39,6 +46,8 @@ public Optional getStudentById(Long studentId) {
return Optional.ofNullable(student);
} catch (WebClientResponseException.NotFound ex) {
return Optional.empty();
+ } catch (WebClientResponseException.Forbidden | WebClientResponseException.Unauthorized ex) {
+ throw ex;
} catch (Exception ex) {
log.error("Failed to reach student-service for id {}: {}", studentId, ex.getMessage());
throw new StudentServiceUnavailableException(
@@ -50,10 +59,11 @@ public Optional getStudentById(Long studentId) {
* Cheap existence check, used before creating an enrollment,
* so we don't have to deserialize a full profile just to validate.
*/
- public boolean studentExists(Long studentId) {
+ public boolean studentExists(Long studentId, String bearerToken) {
try {
Boolean exists = studentServiceWebClient.get()
.uri("/api/v1/students/{id}/exists", studentId)
+ .header(HttpHeaders.AUTHORIZATION, bearerToken)
.retrieve()
.bodyToMono(Boolean.class)
.timeout(Duration.ofSeconds(5))
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java
new file mode 100644
index 0000000..b75a6d9
--- /dev/null
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java
@@ -0,0 +1,73 @@
+package com.example.enrollmentservice.config;
+
+import com.example.common.security.JwtTokenValidator;
+import com.example.enrollmentservice.security.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.List;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ @Value("${jwt.secret}")
+ private String jwtSecret;
+
+ @Bean
+ public JwtAuthenticationFilter jwtAuthenticationFilter(
+ JwtTokenValidator jwtTokenValidator) {
+
+ return new JwtAuthenticationFilter(jwtTokenValidator);
+ }
+
+ @Bean
+ public JwtTokenValidator jwtTokenValidator() {
+ return new JwtTokenValidator(jwtSecret);
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity http,
+ JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
+
+ http
+ .csrf(csrf -> csrf.disable())
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/actuator/health").permitAll()
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtAuthenticationFilter,
+ UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOriginPatterns(List.of("*"));
+ configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(List.of("*"));
+ configuration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java
index c244218..996e1f0 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java
@@ -6,10 +6,16 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
+/**
+ * Access rules: any authenticated user (ADMIN or STUDENT) can browse
+ * courses - students need this to see what's available to enroll in.
+ * Only ADMIN can create, update, or delete a course.
+ */
@RestController
@RequestMapping("/api/v1/courses")
@RequiredArgsConstructor
@@ -18,6 +24,7 @@ public class CourseController {
private final CourseService courseService;
@PostMapping
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity createCourse(@Valid @RequestBody CourseDTO courseDTO) {
return new ResponseEntity<>(courseService.createCourse(courseDTO), HttpStatus.CREATED);
}
@@ -33,11 +40,13 @@ public ResponseEntity> getAllCourses() {
}
@PutMapping("/{id}")
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity updateCourse(@PathVariable Long id, @RequestBody CourseDTO courseDTO) {
return ResponseEntity.ok(courseService.updateCourse(id, courseDTO));
}
@DeleteMapping("/{id}")
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity deleteCourse(@PathVariable Long id) {
courseService.deleteCourse(id);
return ResponseEntity.noContent().build();
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java
index 7b740a7..d327e23 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java
@@ -5,13 +5,24 @@
import com.example.enrollmentservice.service.EnrollmentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
+/**
+ * Access rules:
+ * - POST /enrollments: any authenticated user; a STUDENT can only enroll
+ * themselves (enforced in EnrollmentService using the JWT's studentId,
+ * regardless of the body), an ADMIN can enroll anyone.
+ * - GET by student / by id: ADMIN or the owning STUDENT only.
+ * - GET by course, PATCH status/grade: ADMIN only.
+ * - DELETE (drop): ADMIN or the owning STUDENT only.
+ */
@RestController
@RequestMapping("/api/v1/enrollments")
@RequiredArgsConstructor
@@ -20,33 +31,42 @@ public class EnrollmentController {
private final EnrollmentService enrollmentService;
@PostMapping
- public ResponseEntity enroll(@Valid @RequestBody EnrollmentRequestDTO request) {
- return new ResponseEntity<>(enrollmentService.enrollStudent(request), HttpStatus.CREATED);
+ public ResponseEntity enroll(@Valid @RequestBody EnrollmentRequestDTO request,
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return new ResponseEntity<>(enrollmentService.enrollStudent(request, authHeader), HttpStatus.CREATED);
}
@GetMapping("/{id}")
- public ResponseEntity getEnrollment(@PathVariable Long id) {
- return ResponseEntity.ok(enrollmentService.getEnrollmentById(id));
+ public ResponseEntity getEnrollment(@PathVariable Long id,
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return ResponseEntity.ok(enrollmentService.getEnrollmentById(id, authHeader));
}
@GetMapping("/student/{studentId}")
- public ResponseEntity> getByStudent(@PathVariable Long studentId) {
- return ResponseEntity.ok(enrollmentService.getEnrollmentsByStudent(studentId));
+ public ResponseEntity> getByStudent(@PathVariable Long studentId,
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return ResponseEntity.ok(enrollmentService.getEnrollmentsByStudent(studentId, authHeader));
}
@GetMapping("/course/{courseId}")
- public ResponseEntity> getByCourse(@PathVariable Long courseId) {
- return ResponseEntity.ok(enrollmentService.getEnrollmentsByCourse(courseId));
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> getByCourse(@PathVariable Long courseId,
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return ResponseEntity.ok(enrollmentService.getEnrollmentsByCourse(courseId, authHeader));
}
@PatchMapping("/{id}/status")
- public ResponseEntity updateStatus(@PathVariable Long id, @RequestBody Map body) {
- return ResponseEntity.ok(enrollmentService.updateStatus(id, body.get("status")));
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity updateStatus(@PathVariable Long id, @RequestBody Map body,
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return ResponseEntity.ok(enrollmentService.updateStatus(id, body.get("status"), authHeader));
}
@PatchMapping("/{id}/grade")
- public ResponseEntity recordGrade(@PathVariable Long id, @RequestBody Map body) {
- return ResponseEntity.ok(enrollmentService.recordGrade(id, body.get("grade")));
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity recordGrade(@PathVariable Long id, @RequestBody Map body,
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return ResponseEntity.ok(enrollmentService.recordGrade(id, body.get("grade"), authHeader));
}
@DeleteMapping("/{id}")
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java
index 9446314..6d720eb 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java
@@ -1,8 +1,8 @@
package com.example.enrollmentservice.exception;
+/** Thrown when trying to enroll a student in a course that is already full. */
public class CourseCapacityExceededException extends RuntimeException {
-
public CourseCapacityExceededException(String message) {
super(message);
}
-}
\ No newline at end of file
+}
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java
index bfb8957..52e1f93 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java
@@ -3,6 +3,7 @@
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -15,89 +16,55 @@
@RestControllerAdvice
public class GlobalExceptionHandler {
+ @ExceptionHandler(AccessDeniedException.class)
+ public ResponseEntity handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) {
+ return build(HttpStatus.FORBIDDEN, "Forbidden", "You do not have permission to access this resource", req, null);
+ }
+
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
- ErrorResponse error = ErrorResponse.builder()
- .timestamp(LocalDateTime.now())
- .status(HttpStatus.NOT_FOUND.value())
- .error("Not Found")
- .message(ex.getMessage())
- .path(req.getRequestURI())
- .build();
- return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
+ return build(HttpStatus.NOT_FOUND, "Not Found", ex.getMessage(), req, null);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity handleDuplicate(DuplicateResourceException ex, HttpServletRequest req) {
- ErrorResponse error = ErrorResponse.builder()
- .timestamp(LocalDateTime.now())
- .status(HttpStatus.CONFLICT.value())
- .error("Conflict")
- .message(ex.getMessage())
- .path(req.getRequestURI())
- .build();
- return new ResponseEntity<>(error, HttpStatus.CONFLICT);
+ return build(HttpStatus.CONFLICT, "Conflict", ex.getMessage(), req, null);
}
@ExceptionHandler(CourseCapacityExceededException.class)
- public ResponseEntity handleCourseCapacityExceeded(
- CourseCapacityExceededException ex,
- HttpServletRequest request) {
-
- ErrorResponse response = ErrorResponse.builder()
- .timestamp(LocalDateTime.now())
- .status(HttpStatus.CONFLICT.value())
- .error("Course Capacity Exceeded")
- .message(ex.getMessage())
- .path(request.getRequestURI())
- .build();
-
- return new ResponseEntity<>(response, HttpStatus.CONFLICT);
+ public ResponseEntity handleCapacity(CourseCapacityExceededException ex, HttpServletRequest req) {
+ return build(HttpStatus.CONFLICT, "Course Full", ex.getMessage(), req, null);
}
@ExceptionHandler(StudentServiceUnavailableException.class)
- public ResponseEntity handleStudentServiceUnavailable(
- StudentServiceUnavailableException ex,
- HttpServletRequest request) {
-
- ErrorResponse response = ErrorResponse.builder()
- .timestamp(LocalDateTime.now())
- .status(HttpStatus.SERVICE_UNAVAILABLE.value())
- .error("Student Service Unavailable")
- .message(ex.getMessage())
- .path(request.getRequestURI())
- .build();
-
- return new ResponseEntity<>(response, HttpStatus.SERVICE_UNAVAILABLE);
+ public ResponseEntity handleUnavailable(StudentServiceUnavailableException ex, HttpServletRequest req) {
+ return build(HttpStatus.SERVICE_UNAVAILABLE, "Dependency Unavailable", ex.getMessage(), req, null);
}
-
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
Map validationErrors = new HashMap<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
- ErrorResponse error = ErrorResponse.builder()
- .timestamp(LocalDateTime.now())
- .status(HttpStatus.BAD_REQUEST.value())
- .error("Validation Failed")
- .message("One or more fields are invalid")
- .path(req.getRequestURI())
- .validationErrors(validationErrors)
- .build();
- return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
+ return build(HttpStatus.BAD_REQUEST, "Validation Failed", "One or more fields are invalid", req, validationErrors);
}
@ExceptionHandler(Exception.class)
public ResponseEntity handleGeneric(Exception ex, HttpServletRequest req) {
- ErrorResponse error = ErrorResponse.builder()
+ return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", ex.getMessage(), req, null);
+ }
+
+ private ResponseEntity build(HttpStatus status, String error, String message,
+ HttpServletRequest req, Map validationErrors) {
+ ErrorResponse body = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
- .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
- .error("Internal Server Error")
- .message(ex.getMessage())
+ .status(status.value())
+ .error(error)
+ .message(message)
.path(req.getRequestURI())
+ .validationErrors(validationErrors)
.build();
- return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
+ return new ResponseEntity<>(body, status);
}
}
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java
index 165f589..ee282c5 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java
@@ -1,12 +1,8 @@
package com.example.enrollmentservice.exception;
+/** Thrown when enrollment-service cannot reach student-service to validate a student. */
public class StudentServiceUnavailableException extends RuntimeException {
-
public StudentServiceUnavailableException(String message) {
super(message);
}
-
- public StudentServiceUnavailableException(String message, Throwable cause) {
- super(message, cause);
- }
-}
\ No newline at end of file
+}
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..533fca5
--- /dev/null
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java
@@ -0,0 +1,49 @@
+package com.example.enrollmentservice.security;
+
+import com.example.common.security.JwtPrincipal;
+import com.example.common.security.JwtTokenValidator;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+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 java.io.IOException;
+import java.util.List;
+
+
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenValidator jwtTokenValidator;
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain) throws ServletException, IOException {
+
+ String authHeader = request.getHeader("Authorization");
+
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ String token = authHeader.substring(7);
+
+ if (jwtTokenValidator.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
+ JwtPrincipal principal = jwtTokenValidator.extractPrincipal(token);
+ List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + principal.getRole()));
+
+ UsernamePasswordAuthenticationToken authToken =
+ new UsernamePasswordAuthenticationToken(principal, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java
index 6d28307..ffc95c2 100644
--- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java
+++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java
@@ -1,5 +1,7 @@
package com.example.enrollmentservice.service;
+import com.example.common.security.JwtPrincipal;
+import com.example.common.security.SecurityUtils;
import com.example.enrollmentservice.client.StudentClient;
import com.example.enrollmentservice.dto.EnrollmentRequestDTO;
import com.example.enrollmentservice.dto.EnrollmentResponseDTO;
@@ -12,16 +14,24 @@
import com.example.enrollmentservice.repository.CourseRepository;
import com.example.enrollmentservice.repository.EnrollmentRepository;
import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
-import java.util.Optional;
/**
* Orchestrates the student <-> course enrollment lifecycle.
- * This is where the inter-service REST call happens: before an enrollment
- * is created, the student's existence is validated against student-service.
+ *
+ * Access rules enforced here (in addition to @PreAuthorize on the
+ * controller for admin-only actions):
+ * - A STUDENT may only enroll THEMSELVES (their token's studentId is used,
+ * overriding whatever studentId was sent in the request body) and may
+ * only view/drop their OWN enrollments.
+ * - An ADMIN may enroll/view/drop on behalf of any student.
+ *
+ * Every call to student-service forwards the original caller's bearer
+ * token, so student-service applies the exact same ownership rule.
*/
@Service
@RequiredArgsConstructor
@@ -32,89 +42,129 @@ public class EnrollmentService {
private final CourseRepository courseRepository;
private final StudentClient studentClient;
- public EnrollmentResponseDTO enrollStudent(EnrollmentRequestDTO request) {
- // 1. Validate the course exists and has room.
+ public EnrollmentResponseDTO enrollStudent(EnrollmentRequestDTO request, String bearerToken) {
+ JwtPrincipal principal = requirePrincipal();
+
+ // Determine the effective student ID exactly once
+ final Long effectiveStudentId;
+
+ if (principal.isAdmin()) {
+ effectiveStudentId = request.getStudentId();
+ } else {
+ effectiveStudentId = principal.getStudentId();
+ }
+
Course course = courseRepository.findById(request.getCourseId())
- .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + request.getCourseId()));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Course not found with id: " + request.getCourseId()));
+
+ long confirmedCount = enrollmentRepository.countByCourseIdAndStatus(
+ course.getId(),
+ Enrollment.EnrollmentStatus.CONFIRMED);
- long confirmedCount = enrollmentRepository.countByCourseIdAndStatus(course.getId(), Enrollment.EnrollmentStatus.CONFIRMED);
if (confirmedCount >= course.getCapacity()) {
throw new CourseCapacityExceededException(
- "Course '" + course.getCourseCode() + "' has reached its capacity of " + course.getCapacity());
+ "Course '" + course.getCourseCode()
+ + "' has reached its capacity of "
+ + course.getCapacity());
}
- // 2. Validate the student exists via inter-service REST call to student-service.
- StudentDTO student = studentClient.getStudentById(request.getStudentId())
- .orElseThrow(() -> new ResourceNotFoundException("Student not found with id: " + request.getStudentId()));
+ StudentDTO student = studentClient.getStudentById(effectiveStudentId, bearerToken)
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Student not found with id: " + effectiveStudentId));
+
+ if (enrollmentRepository.existsByStudentIdAndCourseId(
+ effectiveStudentId,
+ request.getCourseId())) {
- // 3. Prevent duplicate enrollment.
- if (enrollmentRepository.existsByStudentIdAndCourseId(request.getStudentId(), request.getCourseId())) {
throw new DuplicateResourceException(
- "Student " + request.getStudentId() + " is already enrolled in course " + request.getCourseId());
+ "Student " + effectiveStudentId
+ + " is already enrolled in course "
+ + request.getCourseId());
}
- // 4. Persist the enrollment as CONFIRMED (workflow could route through
- // PENDING -> approval step first; kept simple here).
Enrollment enrollment = Enrollment.builder()
- .studentId(request.getStudentId())
+ .studentId(effectiveStudentId)
.course(course)
.status(Enrollment.EnrollmentStatus.CONFIRMED)
.build();
Enrollment saved = enrollmentRepository.save(enrollment);
+
return EnrollmentResponseDTO.fromEntity(saved, student);
}
@Transactional(readOnly = true)
- public EnrollmentResponseDTO getEnrollmentById(Long id) {
+ public EnrollmentResponseDTO getEnrollmentById(Long id, String bearerToken) {
Enrollment enrollment = enrollmentRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + id));
- StudentDTO student = studentClient.getStudentById(enrollment.getStudentId()).orElse(null);
+ requireAdminOrOwner(enrollment.getStudentId());
+ StudentDTO student = studentClient.getStudentById(enrollment.getStudentId(), bearerToken).orElse(null);
return EnrollmentResponseDTO.fromEntity(enrollment, student);
}
@Transactional(readOnly = true)
- public List getEnrollmentsByStudent(Long studentId) {
- StudentDTO student = studentClient.getStudentById(studentId)
+ public List getEnrollmentsByStudent(Long studentId, String bearerToken) {
+ requireAdminOrOwner(studentId);
+ StudentDTO student = studentClient.getStudentById(studentId, bearerToken)
.orElseThrow(() -> new ResourceNotFoundException("Student not found with id: " + studentId));
return enrollmentRepository.findByStudentId(studentId).stream()
.map(e -> EnrollmentResponseDTO.fromEntity(e, student))
.toList();
}
+ /** ADMIN only - see EnrollmentController's @PreAuthorize. */
@Transactional(readOnly = true)
- public List getEnrollmentsByCourse(Long courseId) {
+ public List getEnrollmentsByCourse(Long courseId, String bearerToken) {
return enrollmentRepository.findByCourseId(courseId).stream()
.map(e -> {
- StudentDTO student = studentClient.getStudentById(e.getStudentId()).orElse(null);
+ StudentDTO student = studentClient.getStudentById(e.getStudentId(), bearerToken).orElse(null);
return EnrollmentResponseDTO.fromEntity(e, student);
})
.toList();
}
- public EnrollmentResponseDTO updateStatus(Long enrollmentId, String status) {
+ /** ADMIN only - see EnrollmentController's @PreAuthorize. */
+ public EnrollmentResponseDTO updateStatus(Long enrollmentId, String status, String bearerToken) {
Enrollment enrollment = enrollmentRepository.findById(enrollmentId)
.orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId));
enrollment.setStatus(Enrollment.EnrollmentStatus.valueOf(status.toUpperCase()));
Enrollment saved = enrollmentRepository.save(enrollment);
- StudentDTO student = studentClient.getStudentById(saved.getStudentId()).orElse(null);
+ StudentDTO student = studentClient.getStudentById(saved.getStudentId(), bearerToken).orElse(null);
return EnrollmentResponseDTO.fromEntity(saved, student);
}
- public EnrollmentResponseDTO recordGrade(Long enrollmentId, Double grade) {
+ /** ADMIN only - see EnrollmentController's @PreAuthorize. */
+ public EnrollmentResponseDTO recordGrade(Long enrollmentId, Double grade, String bearerToken) {
Enrollment enrollment = enrollmentRepository.findById(enrollmentId)
.orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId));
enrollment.setGrade(grade);
enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED);
Enrollment saved = enrollmentRepository.save(enrollment);
- StudentDTO student = studentClient.getStudentById(saved.getStudentId()).orElse(null);
+ StudentDTO student = studentClient.getStudentById(saved.getStudentId(), bearerToken).orElse(null);
return EnrollmentResponseDTO.fromEntity(saved, student);
}
public void dropEnrollment(Long enrollmentId) {
Enrollment enrollment = enrollmentRepository.findById(enrollmentId)
.orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId));
+ requireAdminOrOwner(enrollment.getStudentId());
enrollment.setStatus(Enrollment.EnrollmentStatus.DROPPED);
enrollmentRepository.save(enrollment);
}
+
+ private JwtPrincipal requirePrincipal() {
+ JwtPrincipal principal = SecurityUtils.currentUser();
+ if (principal == null) {
+ throw new AccessDeniedException("Authentication required");
+ }
+ return principal;
+ }
+
+ private void requireAdminOrOwner(Long studentId) {
+ JwtPrincipal principal = requirePrincipal();
+ if (!principal.isAdmin() && !principal.ownsStudentId(studentId)) {
+ throw new AccessDeniedException("You may only access your own enrollments");
+ }
+ }
}
diff --git a/backend/enrollment-service/src/main/resources/application.properties b/backend/enrollment-service/src/main/resources/application.properties
index 8fa63b7..0e9c3b6 100644
--- a/backend/enrollment-service/src/main/resources/application.properties
+++ b/backend/enrollment-service/src/main/resources/application.properties
@@ -2,7 +2,7 @@ spring.application.name=enrollment-service
server.port=8082
# ---- MySQL Datasource ----
-spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:enrollment_db}?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC
+spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:enrollment_db}?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
spring.datasource.username=${DB_USER:root}
spring.datasource.password=${DB_PASSWORD:root}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
@@ -17,6 +17,9 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
# Base URL of student-service. In docker-compose this resolves via the service name.
student-service.base-url=${STUDENT_SERVICE_URL:http://localhost:8081}
+# ---- JWT (must match auth-service's jwt.secret for token validation to succeed) ----
+jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
+
# ---- Actuator ----
management.endpoints.web.exposure.include=health,info,metrics
diff --git a/backend/student-service/Dockerfile b/backend/student-service/Dockerfile
index eb9e205..0e08ffc 100644
--- a/backend/student-service/Dockerfile
+++ b/backend/student-service/Dockerfile
@@ -1,14 +1,18 @@
# ---- Build stage ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
-COPY pom.xml .
-RUN mvn -B dependency:go-offline
-COPY src ./src
-RUN mvn -B clean package -DskipTests
+
+COPY common-lib ./common-lib
+RUN cd common-lib && mvn -B clean install -DskipTests
+
+COPY student-service/pom.xml ./student-service/pom.xml
+RUN cd student-service && mvn -B dependency:go-offline
+COPY student-service/src ./student-service/src
+RUN cd student-service && mvn -B clean package -DskipTests
# ---- Run stage ----
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
-COPY --from=build /app/target/*.jar app.jar
+COPY --from=build /app/student-service/target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/backend/student-service/pom.xml b/backend/student-service/pom.xml
index 31d61ab..67af1a6 100644
--- a/backend/student-service/pom.xml
+++ b/backend/student-service/pom.xml
@@ -22,6 +22,15 @@
+
+ com.example
+ common-lib
+ 1.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
org.springframework.boot
spring-boot-starter-web
diff --git a/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java b/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java
new file mode 100644
index 0000000..1055485
--- /dev/null
+++ b/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java
@@ -0,0 +1,75 @@
+package com.example.studentservice.config;
+
+import com.example.studentservice.security.JwtAuthenticationFilter;
+import com.example.common.security.JwtTokenValidator;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.List;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+
+
+ @Value("${jwt.secret}")
+ private String jwtSecret;
+
+ @Bean
+ public JwtAuthenticationFilter jwtAuthenticationFilter(
+ JwtTokenValidator jwtTokenValidator) {
+
+ return new JwtAuthenticationFilter(jwtTokenValidator);
+ }
+
+ @Bean
+ public JwtTokenValidator jwtTokenValidator() {
+ return new JwtTokenValidator(jwtSecret);
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity http,
+ JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
+
+ http
+ .csrf(csrf -> csrf.disable())
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/actuator/health").permitAll()
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtAuthenticationFilter,
+ UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOriginPatterns(List.of("*"));
+ configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(List.of("*"));
+ configuration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java b/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java
index e1149e3..3e6140f 100644
--- a/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java
+++ b/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java
@@ -1,5 +1,7 @@
package com.example.studentservice.controller;
+import com.example.common.security.JwtPrincipal;
+import com.example.common.security.SecurityUtils;
import com.example.studentservice.dto.StudentDTO;
import com.example.studentservice.service.StudentService;
import jakarta.validation.Valid;
@@ -9,6 +11,8 @@
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -16,6 +20,12 @@
/**
* REST API for student CRUD operations.
* Base path: /api/v1/students
+ *
+ * Access rules:
+ * - ADMIN can do everything.
+ * - STUDENT can only read/update their OWN profile (matched via the
+ * studentId claim embedded in their JWT by auth-service), and cannot
+ * list/search all students, create, or delete any profile.
*/
@RestController
@RequestMapping("/api/v1/students")
@@ -25,6 +35,7 @@ public class StudentController {
private final StudentService studentService;
@PostMapping
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity createStudent(@Valid @RequestBody StudentDTO studentDTO) {
StudentDTO created = studentService.createStudent(studentDTO);
return new ResponseEntity<>(created, HttpStatus.CREATED);
@@ -32,15 +43,18 @@ public ResponseEntity createStudent(@Valid @RequestBody StudentDTO s
@GetMapping("/{id}")
public ResponseEntity getStudentById(@PathVariable Long id) {
+ requireAdminOrOwner(id);
return ResponseEntity.ok(studentService.getStudentById(id));
}
@GetMapping("/email/{email}")
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity getStudentByEmail(@PathVariable String email) {
return ResponseEntity.ok(studentService.getStudentByEmail(email));
}
@GetMapping
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity> getStudents(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@@ -62,10 +76,12 @@ public ResponseEntity> getStudents(
@PutMapping("/{id}")
public ResponseEntity updateStudent(@PathVariable Long id, @RequestBody StudentDTO studentDTO) {
+ requireAdminOrOwner(id);
return ResponseEntity.ok(studentService.updateStudent(id, studentDTO));
}
@DeleteMapping("/{id}")
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity deleteStudent(@PathVariable Long id) {
studentService.deleteStudent(id);
return ResponseEntity.noContent().build();
@@ -73,10 +89,18 @@ public ResponseEntity deleteStudent(@PathVariable Long id) {
/**
* Lightweight existence check used by enrollment-service before
- * creating an enrollment, to avoid pulling the full profile.
+ * creating an enrollment. Any authenticated caller may use this -
+ * it leaks no profile data, only a boolean.
*/
@GetMapping("/{id}/exists")
public ResponseEntity existsById(@PathVariable Long id) {
return ResponseEntity.ok(studentService.existsById(id));
}
+
+ private void requireAdminOrOwner(Long studentId) {
+ JwtPrincipal principal = SecurityUtils.currentUser();
+ if (principal == null || (!principal.isAdmin() && !principal.ownsStudentId(studentId))) {
+ throw new AccessDeniedException("You may only access your own student record");
+ }
+ }
}
diff --git a/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java b/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java
index b999363..d38dd99 100644
--- a/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java
+++ b/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java
@@ -3,6 +3,7 @@
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -15,6 +16,18 @@
@RestControllerAdvice
public class GlobalExceptionHandler {
+ @ExceptionHandler(AccessDeniedException.class)
+ public ResponseEntity handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) {
+ ErrorResponse error = ErrorResponse.builder()
+ .timestamp(LocalDateTime.now())
+ .status(HttpStatus.FORBIDDEN.value())
+ .error("Forbidden")
+ .message("You do not have permission to access this resource")
+ .path(req.getRequestURI())
+ .build();
+ return new ResponseEntity<>(error, HttpStatus.FORBIDDEN);
+ }
+
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
ErrorResponse error = ErrorResponse.builder()
diff --git a/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java b/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..ca0d83c
--- /dev/null
+++ b/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java
@@ -0,0 +1,57 @@
+package com.example.studentservice.security;
+
+import com.example.common.security.JwtPrincipal;
+import com.example.common.security.JwtTokenValidator;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+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 java.io.IOException;
+import java.util.List;
+
+/**
+ * Validates the Bearer token issued by auth-service and populates the
+ * SecurityContext with a JwtPrincipal, so controllers can enforce
+ * ownership rules (e.g. a STUDENT may only read/update their own record).
+ * This service does NOT call auth-service to validate - it verifies the
+ * JWT signature locally using the shared secret, which is the whole
+ * point of using signed, stateless access tokens between services.
+ */
+
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenValidator jwtTokenValidator;
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain) throws ServletException, IOException {
+
+ String authHeader = request.getHeader("Authorization");
+
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ String token = authHeader.substring(7);
+
+ if (jwtTokenValidator.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
+ JwtPrincipal principal = jwtTokenValidator.extractPrincipal(token);
+ List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + principal.getRole()));
+
+ UsernamePasswordAuthenticationToken authToken =
+ new UsernamePasswordAuthenticationToken(principal, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/backend/student-service/src/main/resources/application.properties b/backend/student-service/src/main/resources/application.properties
index 66231ae..c489235 100644
--- a/backend/student-service/src/main/resources/application.properties
+++ b/backend/student-service/src/main/resources/application.properties
@@ -13,6 +13,9 @@ spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
+# ---- JWT (must match auth-service's jwt.secret for token validation to succeed) ----
+jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
+
# ---- Actuator ----
management.endpoints.web.exposure.include=health,info,metrics
diff --git a/docker-compose.yml b/docker-compose.yml
index 19b4f4f..39a7937 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,25 @@
version: "3.8"
services:
+ mysql-auth:
+ image: mysql:8.0
+ container_name: mysql-auth
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: auth_db
+ ports:
+ - "3310:3306"
+ volumes:
+ - mysql_auth_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - sms-net
+
mysql-student:
image: mysql:8.0
container_name: mysql-student
@@ -18,7 +37,7 @@ services:
timeout: 5s
retries: 5
networks:
- - student-net
+ - sms-net
mysql-enrollment:
image: mysql:8.0
@@ -37,10 +56,33 @@ services:
timeout: 5s
retries: 5
networks:
- - student-net
+ - sms-net
+
+ auth-service:
+ build:
+ context: .
+ dockerfile: auth-service/Dockerfile
+ container_name: auth-service
+ restart: unless-stopped
+ depends_on:
+ mysql-auth:
+ condition: service_healthy
+ environment:
+ DB_HOST: mysql-auth
+ DB_PORT: 3306
+ DB_NAME: auth_db
+ DB_USER: root
+ DB_PASSWORD: root
+ JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
+ ports:
+ - "8080:8080"
+ networks:
+ - sms-net
student-service:
- build: ./student-service
+ build:
+ context: .
+ dockerfile: student-service/Dockerfile
container_name: student-service
restart: unless-stopped
depends_on:
@@ -52,13 +94,16 @@ services:
DB_NAME: student_db
DB_USER: root
DB_PASSWORD: root
+ JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
ports:
- "8081:8081"
networks:
- - student-net
+ - sms-net
enrollment-service:
- build: ./enrollment-service
+ build:
+ context: .
+ dockerfile: enrollment-service/Dockerfile
container_name: enrollment-service
restart: unless-stopped
depends_on:
@@ -72,16 +117,38 @@ services:
DB_NAME: enrollment_db
DB_USER: root
DB_PASSWORD: root
+ JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
STUDENT_SERVICE_URL: http://student-service:8081
ports:
- "8082:8082"
networks:
- - student-net
+ - sms-net
+
+ api-gateway:
+ build:
+ context: .
+ dockerfile: api-gateway/Dockerfile
+ container_name: api-gateway
+ restart: unless-stopped
+ depends_on:
+ - auth-service
+ - student-service
+ - enrollment-service
+ environment:
+ JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}
+ AUTH_SERVICE_URL: http://auth-service:8080
+ STUDENT_SERVICE_URL: http://student-service:8081
+ ENROLLMENT_SERVICE_URL: http://enrollment-service:8082
+ ports:
+ - "9000:9000"
+ networks:
+ - sms-net
networks:
- student-net:
+ sms-net:
driver: bridge
volumes:
+ mysql_auth_data:
mysql_student_data:
mysql_enrollment_data:
diff --git a/postman/Phase2-Gateway-Flow.postman_collection.json b/postman/Phase2-Gateway-Flow.postman_collection.json
new file mode 100644
index 0000000..4780dce
--- /dev/null
+++ b/postman/Phase2-Gateway-Flow.postman_collection.json
@@ -0,0 +1,197 @@
+{
+ "info": {
+ "name": "Student Management System - Phase 2 (via API Gateway)",
+ "description": "Full auth + role-based workflow, routed through api-gateway on port 9000",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ { "key": "gatewayUrl", "value": "http://localhost:9000/api/v1" },
+ { "key": "studentAccessToken", "value": "" },
+ { "key": "studentRefreshToken", "value": "" },
+ { "key": "adminAccessToken", "value": "" }
+ ],
+ "item": [
+ {
+ "name": "1. Auth",
+ "item": [
+ {
+ "name": "Register (creates a STUDENT account)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "url": "{{gatewayUrl}}/auth/register",
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"fullName\": \"Asha Rao\",\n \"email\": \"asha.rao@example.com\",\n \"password\": \"SecurePass123\"\n}"
+ }
+ }
+ },
+ {
+ "name": "Login as Student",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "url": "{{gatewayUrl}}/auth/login",
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"email\": \"asha.rao@example.com\",\n \"password\": \"SecurePass123\"\n}"
+ }
+ }
+ },
+ {
+ "name": "Refresh Token",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "url": "{{gatewayUrl}}/auth/refresh",
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"refreshToken\": \"{{studentRefreshToken}}\"\n}"
+ }
+ }
+ },
+ {
+ "name": "Logout",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "url": "{{gatewayUrl}}/auth/logout",
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"refreshToken\": \"{{studentRefreshToken}}\"\n}"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "2. Admin - User Management (requires an ADMIN token)",
+ "item": [
+ {
+ "name": "List All Users",
+ "request": {
+ "method": "GET",
+ "header": [{ "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }],
+ "url": "{{gatewayUrl}}/admin/users"
+ }
+ },
+ {
+ "name": "Promote a User to ADMIN",
+ "request": {
+ "method": "PATCH",
+ "header": [
+ { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" },
+ { "key": "Content-Type", "value": "application/json" }
+ ],
+ "url": "{{gatewayUrl}}/admin/users/1/role",
+ "body": { "mode": "raw", "raw": "{\n \"role\": \"ADMIN\"\n}" }
+ }
+ }
+ ]
+ },
+ {
+ "name": "3. Students (ownership enforced)",
+ "item": [
+ {
+ "name": "Admin Creates a Student Profile",
+ "request": {
+ "method": "POST",
+ "header": [
+ { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" },
+ { "key": "Content-Type", "value": "application/json" }
+ ],
+ "url": "{{gatewayUrl}}/students",
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"firstName\": \"Asha\",\n \"lastName\": \"Rao\",\n \"email\": \"asha.rao@example.com\",\n \"dateOfBirth\": \"2001-05-12\"\n}"
+ }
+ }
+ },
+ {
+ "name": "Student Views Own Profile (should succeed)",
+ "request": {
+ "method": "GET",
+ "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }],
+ "url": "{{gatewayUrl}}/students/1"
+ }
+ },
+ {
+ "name": "Student Views Someone Else's Profile (should 403)",
+ "request": {
+ "method": "GET",
+ "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }],
+ "url": "{{gatewayUrl}}/students/2"
+ }
+ },
+ {
+ "name": "Admin Lists All Students",
+ "request": {
+ "method": "GET",
+ "header": [{ "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }],
+ "url": "{{gatewayUrl}}/students?unpaged=true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "4. Courses & Enrollments (ownership enforced)",
+ "item": [
+ {
+ "name": "Admin Creates a Course",
+ "request": {
+ "method": "POST",
+ "header": [
+ { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" },
+ { "key": "Content-Type", "value": "application/json" }
+ ],
+ "url": "{{gatewayUrl}}/courses",
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"courseCode\": \"CS101\",\n \"title\": \"Intro to Computer Science\",\n \"credits\": 4,\n \"capacity\": 30\n}"
+ }
+ }
+ },
+ {
+ "name": "Student Views Available Courses",
+ "request": {
+ "method": "GET",
+ "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }],
+ "url": "{{gatewayUrl}}/courses"
+ }
+ },
+ {
+ "name": "Student Enrolls Themselves",
+ "request": {
+ "method": "POST",
+ "header": [
+ { "key": "Authorization", "value": "Bearer {{studentAccessToken}}" },
+ { "key": "Content-Type", "value": "application/json" }
+ ],
+ "url": "{{gatewayUrl}}/enrollments",
+ "body": { "mode": "raw", "raw": "{\n \"studentId\": 1,\n \"courseId\": 1\n}" }
+ }
+ },
+ {
+ "name": "Student Views Own Enrollments",
+ "request": {
+ "method": "GET",
+ "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }],
+ "url": "{{gatewayUrl}}/enrollments/student/1"
+ }
+ },
+ {
+ "name": "Admin Records a Grade",
+ "request": {
+ "method": "PATCH",
+ "header": [
+ { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" },
+ { "key": "Content-Type", "value": "application/json" }
+ ],
+ "url": "{{gatewayUrl}}/enrollments/1/grade",
+ "body": { "mode": "raw", "raw": "{\n \"grade\": 8.7\n}" }
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/postman/Student-Management-Microservices.postman_collection.json b/postman/Student-Management-Microservices.postman_collection.json
deleted file mode 100644
index f7a2928..0000000
--- a/postman/Student-Management-Microservices.postman_collection.json
+++ /dev/null
@@ -1,118 +0,0 @@
-{
- "info": {
- "name": "Student Management Microservices",
- "description": "CRUD + inter-service workflow tests for student-service and enrollment-service",
- "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
- },
- "variable": [
- { "key": "studentBaseUrl", "value": "http://localhost:8081/api/v1" },
- { "key": "enrollmentBaseUrl", "value": "http://localhost:8082/api/v1" }
- ],
- "item": [
- {
- "name": "Student Service",
- "item": [
- {
- "name": "Create Student",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "url": "{{studentBaseUrl}}/students",
- "body": {
- "mode": "raw",
- "raw": "{\n \"firstName\": \"Asha\",\n \"lastName\": \"Rao\",\n \"email\": \"asha.rao@example.com\",\n \"phoneNumber\": \"9876543210\",\n \"dateOfBirth\": \"2001-05-12\"\n}"
- }
- }
- },
- {
- "name": "Get Student By Id",
- "request": { "method": "GET", "url": "{{studentBaseUrl}}/students/1" }
- },
- {
- "name": "Get All Students",
- "request": { "method": "GET", "url": "{{studentBaseUrl}}/students?unpaged=true" }
- },
- {
- "name": "Search Students",
- "request": { "method": "GET", "url": "{{studentBaseUrl}}/students?keyword=asha&page=0&size=10" }
- },
- {
- "name": "Update Student",
- "request": {
- "method": "PUT",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "url": "{{studentBaseUrl}}/students/1",
- "body": { "mode": "raw", "raw": "{\n \"phoneNumber\": \"9999999999\"\n}" }
- }
- },
- {
- "name": "Delete Student",
- "request": { "method": "DELETE", "url": "{{studentBaseUrl}}/students/1" }
- }
- ]
- },
- {
- "name": "Enrollment Service",
- "item": [
- {
- "name": "Create Course",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "url": "{{enrollmentBaseUrl}}/courses",
- "body": {
- "mode": "raw",
- "raw": "{\n \"courseCode\": \"CS101\",\n \"title\": \"Intro to Computer Science\",\n \"description\": \"Fundamentals of CS\",\n \"credits\": 4,\n \"capacity\": 30\n}"
- }
- }
- },
- {
- "name": "Get All Courses",
- "request": { "method": "GET", "url": "{{enrollmentBaseUrl}}/courses" }
- },
- {
- "name": "Enroll Student (calls student-service internally)",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "url": "{{enrollmentBaseUrl}}/enrollments",
- "body": {
- "mode": "raw",
- "raw": "{\n \"studentId\": 1,\n \"courseId\": 1\n}"
- }
- }
- },
- {
- "name": "Get Enrollments By Student",
- "request": { "method": "GET", "url": "{{enrollmentBaseUrl}}/enrollments/student/1" }
- },
- {
- "name": "Get Enrollments By Course",
- "request": { "method": "GET", "url": "{{enrollmentBaseUrl}}/enrollments/course/1" }
- },
- {
- "name": "Update Enrollment Status",
- "request": {
- "method": "PATCH",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "url": "{{enrollmentBaseUrl}}/enrollments/1/status",
- "body": { "mode": "raw", "raw": "{\n \"status\": \"COMPLETED\"\n}" }
- }
- },
- {
- "name": "Record Grade",
- "request": {
- "method": "PATCH",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "url": "{{enrollmentBaseUrl}}/enrollments/1/grade",
- "body": { "mode": "raw", "raw": "{\n \"grade\": 8.7\n}" }
- }
- },
- {
- "name": "Drop Enrollment",
- "request": { "method": "DELETE", "url": "{{enrollmentBaseUrl}}/enrollments/1" }
- }
- ]
- }
- ]
-}