Skip to content
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")
Expand All @@ -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() =
Expand Down
12 changes: 12 additions & 0 deletions src/main/kotlin/dev/whrite/opsgrid/security/JwtActor.kt
Original file line number Diff line number Diff line change
@@ -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"
)
81 changes: 81 additions & 0 deletions src/main/kotlin/dev/whrite/opsgrid/security/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -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, Collection<GrantedAuthority>> { jwt ->
val directRoles = (jwt.claims["roles"] as? Collection<*>)
?.filterIsInstance<String>()
.orEmpty()
val realmAccess = jwt.claims["realm_access"] as? Map<*, *>
val realmRoles = (realmAccess?.get("roles") as? Collection<*>)
?.filterIsInstance<String>()
.orEmpty()

(directRoles + realmRoles)
.mapNotNull { role ->
role.trim()
.removePrefix("ROLE_")
.takeIf(String::isNotBlank)
?.let { SimpleGrantedAuthority("ROLE_$it") }
}
.distinct()
}
)
return converter
}
}
15 changes: 9 additions & 6 deletions src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
@@ -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<String, Any>): 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()
}
}
Original file line number Diff line number Diff line change
@@ -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"}"""
}
Loading