diff --git a/README.md b/README.md index 3dc9c11..b01444d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,15 @@ Schedule a shift with `POST /api/v1/shifts`. Update and cancel shifts with `PUT Create or cancel leave with `POST /api/v1/leave` and `DELETE /api/v1/leave/{id}`. -Mutation requests must include an `X-Actor-Id` header. The value is recorded for audit attribution. **It is not authentication**: until authentication and role-based access control are implemented, a caller can supply an arbitrary actor value. Do not expose mutation endpoints to untrusted clients based on this header alone. +All API routes require a valid bearer access token from an OpenID Connect provider. Configure `JWT_ISSUER_URI` and `JWT_JWK_SET_URI`; the defaults point to a local Keycloak realm placeholder and do not provision an identity provider. Shift and leave audit records use the authenticated token's `sub` claim. The API no longer trusts a caller-supplied `X-Actor-Id` header. + +Role claims are read from either a top-level `roles` array or Keycloak's `realm_access.roles` array. Required roles: +- `POST /api/v1/employees`: `ADMIN` or `HR` +- Create, update and cancel shifts: `ADMIN` or `SCHEDULER` +- Create and cancel leave: `ADMIN` or `HR` +- Read endpoints: any authenticated user + +The API is stateless and validates JWT signatures using the configured JWK set, plus the configured issuer. Role names are mapped to Spring authorities such as `ROLE_SCHEDULER`. This repository configures the resource server; you must still provision an identity provider, users, clients, and role assignments in your deployment. A scheduling conflict returns HTTP 409 with code `SHIFT_CONFLICT`; invalid time windows return HTTP 400. diff --git a/build.gradle.kts b/build.gradle.kts index 67726af..e2c4b66 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,8 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.flywaydb:flyway-core") @@ -30,6 +32,7 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") } diff --git a/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt b/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt index 1d7cc16..1a00d61 100644 --- a/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt +++ b/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt @@ -4,6 +4,9 @@ import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import dev.whrite.opsgrid.security.requireActorId import org.springframework.web.bind.annotation.* import java.time.OffsetDateTime import java.util.UUID @@ -30,11 +33,11 @@ class EmployeeLeaveController(private val service: EmployeeLeaveService) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestHeader("X-Actor-Id") actorId: String, + @AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: CreateLeaveRequest ) = service.approve( CreateLeaveCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!, request.reason), - actorId + jwt.requireActorId() ).toResponse() @GetMapping("/{id}") @@ -43,8 +46,8 @@ class EmployeeLeaveController(private val service: EmployeeLeaveService) { @DeleteMapping("/{id}") fun cancel( @PathVariable id: UUID, - @RequestHeader("X-Actor-Id") actorId: String - ) = service.cancel(id, actorId).toResponse() + @AuthenticationPrincipal jwt: Jwt + ) = service.cancel(id, jwt.requireActorId()).toResponse() } private fun EmployeeLeave.toResponse() = diff --git a/src/main/kotlin/dev/whrite/opsgrid/security/JwtActor.kt b/src/main/kotlin/dev/whrite/opsgrid/security/JwtActor.kt new file mode 100644 index 0000000..fa4d498 --- /dev/null +++ b/src/main/kotlin/dev/whrite/opsgrid/security/JwtActor.kt @@ -0,0 +1,12 @@ +package dev.whrite.opsgrid.security + +import org.springframework.http.HttpStatus +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.server.ResponseStatusException + +fun Jwt.requireActorId(): String = + subject?.takeIf { it.isNotBlank() } + ?: throw ResponseStatusException( + HttpStatus.UNAUTHORIZED, + "The access token must contain a subject claim" + ) diff --git a/src/main/kotlin/dev/whrite/opsgrid/security/SecurityConfig.kt b/src/main/kotlin/dev/whrite/opsgrid/security/SecurityConfig.kt new file mode 100644 index 0000000..3e46ab0 --- /dev/null +++ b/src/main/kotlin/dev/whrite/opsgrid/security/SecurityConfig.kt @@ -0,0 +1,81 @@ +package dev.whrite.opsgrid.security + +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.config.http.SessionCreationPolicy +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.JwtAuthenticationConverter +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.config.annotation.web.builders.HttpSecurity + +@Configuration +class SecurityConfig { + @Bean + fun securityFilterChain( + http: HttpSecurity, + jwtAuthenticationConverter: JwtAuthenticationConverter + ): SecurityFilterChain { + http.csrf { it.disable() } + http.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + http.authorizeHttpRequests { + it.requestMatchers(HttpMethod.POST, "/api/v1/employees").hasAnyRole("ADMIN", "HR") + it.requestMatchers( + HttpMethod.POST, + "/api/v1/shifts" + ).hasAnyRole("ADMIN", "SCHEDULER") + it.requestMatchers( + HttpMethod.PUT, + "/api/v1/shifts/**" + ).hasAnyRole("ADMIN", "SCHEDULER") + it.requestMatchers( + HttpMethod.DELETE, + "/api/v1/shifts/**" + ).hasAnyRole("ADMIN", "SCHEDULER") + it.requestMatchers( + HttpMethod.POST, + "/api/v1/leave" + ).hasAnyRole("ADMIN", "HR") + it.requestMatchers( + HttpMethod.DELETE, + "/api/v1/leave/**" + ).hasAnyRole("ADMIN", "HR") + it.anyRequest().authenticated() + } + http.oauth2ResourceServer { + it.jwt { jwt -> + jwt.jwtAuthenticationConverter(jwtAuthenticationConverter) + } + } + return http.build() + } + + @Bean + fun jwtAuthenticationConverter(): JwtAuthenticationConverter { + val converter = JwtAuthenticationConverter() + converter.setJwtGrantedAuthoritiesConverter( + Converter> { jwt -> + val directRoles = (jwt.claims["roles"] as? Collection<*>) + ?.filterIsInstance() + .orEmpty() + val realmAccess = jwt.claims["realm_access"] as? Map<*, *> + val realmRoles = (realmAccess?.get("roles") as? Collection<*>) + ?.filterIsInstance() + .orEmpty() + + (directRoles + realmRoles) + .mapNotNull { role -> + role.trim() + .removePrefix("ROLE_") + .takeIf(String::isNotBlank) + ?.let { SimpleGrantedAuthority("ROLE_$it") } + } + .distinct() + } + ) + return converter + } +} diff --git a/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt b/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt index 8bd59c4..dda9244 100644 --- a/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt +++ b/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt @@ -3,6 +3,9 @@ package dev.whrite.opsgrid.shift import jakarta.validation.Valid import jakarta.validation.constraints.NotNull import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import dev.whrite.opsgrid.security.requireActorId import org.springframework.web.bind.annotation.* import java.time.OffsetDateTime import java.util.UUID @@ -32,11 +35,11 @@ class ShiftController(private val service: ShiftService) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestHeader("X-Actor-Id") actorId: String, + @AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: CreateShiftRequest ) = service.schedule( CreateShiftCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!), - actorId + jwt.requireActorId() ).toResponse() @GetMapping("/{id}") @@ -45,19 +48,19 @@ class ShiftController(private val service: ShiftService) { @PutMapping("/{id}") fun update( @PathVariable id: UUID, - @RequestHeader("X-Actor-Id") actorId: String, + @AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: UpdateShiftRequest ) = service.update( id, UpdateShiftCommand(request.startsAt!!, request.endsAt!!), - actorId + jwt.requireActorId() ).toResponse() @DeleteMapping("/{id}") fun cancel( @PathVariable id: UUID, - @RequestHeader("X-Actor-Id") actorId: String - ) = service.cancel(id, actorId).toResponse() + @AuthenticationPrincipal jwt: Jwt + ) = service.cancel(id, jwt.requireActorId()).toResponse() @GetMapping("/employee/{employeeId}") fun schedule( diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0463df7..4ca17da 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,5 +12,12 @@ spring: flyway: enabled: true + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${JWT_ISSUER_URI:http://localhost:8081/realms/opsgrid} + jwk-set-uri: ${JWT_JWK_SET_URI:http://localhost:8081/realms/opsgrid/protocol/openid-connect/certs} + server: port: ${PORT:8080} diff --git a/src/test/kotlin/dev/whrite/opsgrid/security/JwtAuthenticationConverterTest.kt b/src/test/kotlin/dev/whrite/opsgrid/security/JwtAuthenticationConverterTest.kt new file mode 100644 index 0000000..61e9d81 --- /dev/null +++ b/src/test/kotlin/dev/whrite/opsgrid/security/JwtAuthenticationConverterTest.kt @@ -0,0 +1,48 @@ +package dev.whrite.opsgrid.security + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.security.oauth2.jwt.Jwt +import java.time.Instant + +class JwtAuthenticationConverterTest { + private val converter = SecurityConfig().jwtAuthenticationConverter() + + @Test + fun mapsTopLevelRolesToSpringAuthorities() { + val jwt = token( + mapOf( + "roles" to listOf("ADMIN", "SCHEDULER", "ROLE_HR") + ) + ) + + val authorities = converter.convert(jwt)!!.authorities.map { it.authority }.toSet() + + assertEquals(setOf("ROLE_ADMIN", "ROLE_SCHEDULER", "ROLE_HR"), authorities) + } + + @Test + fun mapsKeycloakRealmRolesAndDeduplicatesAuthorities() { + val jwt = token( + mapOf( + "roles" to listOf("SCHEDULER"), + "realm_access" to mapOf("roles" to listOf("SCHEDULER", "HR")) + ) + ) + + val authorities = converter.convert(jwt)!!.authorities.map { it.authority }.toSet() + + assertEquals(setOf("ROLE_SCHEDULER", "ROLE_HR"), authorities) + } + + private fun token(claims: Map): Jwt { + val now = Instant.now() + return Jwt.withTokenValue("test-token") + .header("alg", "RS256") + .subject("test-operator") + .issuedAt(now) + .expiresAt(now.plusSeconds(300)) + .claims { it.putAll(claims) } + .build() + } +} diff --git a/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftControllerSecurityTest.kt b/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftControllerSecurityTest.kt new file mode 100644 index 0000000..faac73b --- /dev/null +++ b/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftControllerSecurityTest.kt @@ -0,0 +1,75 @@ +package dev.whrite.opsgrid.shift + +import dev.whrite.opsgrid.employee.Employee +import dev.whrite.opsgrid.security.SecurityConfig +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.OffsetDateTime +import java.util.UUID + +@WebMvcTest(controllers = [ShiftController::class]) +@Import(SecurityConfig::class) +class ShiftControllerSecurityTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var service: ShiftService + + @Test + fun rejectsUnauthenticatedShiftMutation() { + mockMvc.perform( + post("/api/v1/shifts") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody()) + ).andExpect(status().isUnauthorized) + } + + @Test + fun employeeRoleCannotCreateShift() { + mockMvc.perform( + post("/api/v1/shifts") + .with(jwt().authorities(SimpleGrantedAuthority("ROLE_EMPLOYEE"))) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody()) + ).andExpect(status().isForbidden) + } + + @Test + fun schedulerCanCreateShiftAndAuditActorComesFromTokenSubject() { + val employeeId = UUID.randomUUID() + val employee = Employee(employeeId, "EMP-SEC-01", "Test", "Scheduler", "Operations") + val startsAt = OffsetDateTime.parse("2026-10-20T08:00:00Z") + val endsAt = OffsetDateTime.parse("2026-10-20T16:00:00Z") + val shift = Shift(UUID.randomUUID(), employee, startsAt, endsAt) + whenever(service.schedule(any(), eq("scheduler-1"))).thenReturn(shift) + + mockMvc.perform( + post("/api/v1/shifts") + .with( + jwt() + .jwt { it.subject("scheduler-1") } + .authorities(SimpleGrantedAuthority("ROLE_SCHEDULER")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody(employeeId)) + ).andExpect(status().isCreated) + + verify(service).schedule(any(), eq("scheduler-1")) + } + + private fun requestBody(employeeId: UUID = UUID.randomUUID()) = + """{"employeeId":"$employeeId","startsAt":"2026-10-20T08:00:00Z","endsAt":"2026-10-20T16:00:00Z"}""" +}