visit
Last week, I wrote about putting the right feature at the right place. I used rate limiting as an example, moving it from a library inside the application to the API Gateway. Today, I'll use another example: authentication and authorization.
Create a policy that allows users to request their own salary as well as the salary of their direct subordinates.
In any other case, return a 401
.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
@SpringBootApplication
@EnableWebSecurity
class SecureBootApplication
internal fun security() = beans { //1
bean {
val http = ref<HttpSecurity>()
http {
authorizeRequests {
authorize("/finance/salary/**", authenticated) //2
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(
TokenAuthenticationFilter(ref()) //3
)
httpBasic { disable() }
csrf { disable() }
logout { disable() }
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
}
http.build()
}
bean { TokenAuthenticationManager(ref(), ref()) } //4
}
curl -H 'Authorization: xyz' localhost:9080/finance/salary/bob
internal class TokenAuthenticationFilter(authManager: AuthenticationManager) :
AbstractAuthenticationProcessingFilter("/finance/salary/**", authManager) {
override fun attemptAuthentication(req: HttpServletRequest, resp: HttpServletResponse): Authentication {
val header = req.getHeader("Authorization") //1
val path = req.servletPath.split('/') //2
val token = KeyToken(header, path) //3
return authenticationManager.authenticate(token) //4
}
// override fun successfulAuthentication(
}
internal class TokenAuthenticationManager(
private val accountRepo: AccountRepository,
private val employeeRepo: EmployeeRepository
) : AuthenticationManager {
override fun authenticate(authentication: Authentication): Authentication {
val token = authentication.credentials as String? ?: //1
throw BadCredentialsException("No token passed")
val account = accountRepo.findByPassword(token).orElse(null) ?: //2
throw BadCredentialsException("Invalid token")
val path = authentication.details as List<String>
val accountId = account.id
val segment = path.last()
if (segment == accountId) return authentication.withPrincipal(accountId) //3
val employee = employeeRepo.findById(segment).orElse(null) //4
val managerUserName = employee?.manager?.userName
if (managerUserName != null && managerUserName == accountId) //5
return authentication.withPrincipal(accountId) //5
throw InsufficientAuthenticationException("Incorrect token") //6
}
}
curl -H 'Authorization: bob' localhost:9080/finance/salary/bob
bob
asks for his own salary, and it works.
curl -H 'Authorization: bob' localhost:9080/finance/salary/alice
bob
asks for the salary of one of his subordinates, and it works as well.
curl -H 'Authorization: bob' localhost:9080/finance/salary/alice
alice
asks for her manager's salary, which is not allowed.
Stop using a different policy language, policy model, and policy API for every product and service you use. Use OPA for a unified toolset and framework for policy across the cloud native stack. Whether for one service or for all your services, use OPA to decouple policy from the service's code so you can release, analyze, and review policies (which security and compliance teams love) without sacrificing availability or performance.--
package ch.frankel.blog.secureboot
employees := data.hierarchy //1
default allow := false
# Allow users to get their own salaries.
allow {
input.path == ["finance", "salary", input.user] //2
}
# Allow managers to get their subordinates' salaries.
allow {
some username
input.path = ["finance", "salary", username] //3
employees[input.user][_] == username //3
}
I used two variables in the above snippet: input
and data
. input
is the payload that the application sends to OPA. It should be in JSON format and has the following form:
{
"path": [
"finance",
"salary",
"alice"
],
"user": "bob"
}
However, OPA can't decide on the input alone, as it doesn't know the employee's hierarchy. One approach would be to load the hierarchy data on the app and send it to OPA. A more robust approach is to let OPA access external data to separate responsibilities cleanly. OPA offers to achieve it. Here, I pretend to extract data from the Employee
database, bundle it together with the policy file, serve the bundle via HTTP, and configure OPA to load it at regular intervals.
Note that you shouldn't use Apache APISIX only to serve static files. But since I'll be using it in the next evolution of my architecture, I want to avoid having a separate HTTP server to simplify the system.
internal class OpaAuthenticationManager(
private val accountRepo: AccountRepository,
private val opaWebClient: WebClient
) : AuthenticationManager {
override fun authenticate(authentication: Authentication): Authentication {
val token = authentication.credentials as String? ?: //1
throw BadCredentialsException("No token passed")
val account = accountRepo.findByPassword(token).orElse(null) ?: //1
throw BadCredentialsException("Invalid token")
val path = authentication.details as List<String>
val decision = opaWebClient.post() //2
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(OpaInput(DataInput(account.id, path))) //3
.exchangeToMono { it.bodyToMono(DecisionOutput::class.java) } //4
.block() ?: DecisionOutput(ResultOutput(false)) //5
if (decision.result.allow) return authentication.withPrincipal(account.id) //6
else throw InsufficientAuthenticationException("OPA disallow") //6
}
}
At this point, we moved the authorization logic from the code to OPA.
The next and final step is to move the authentication logic The obvious candidate is the API Gateway since we set Apache APISIX in the previous step. In general, we should use the capabilities of the API Gateway as much as possible and fall back to libraries for the rest.
Apache APISIX has multiple authentication plugins available. Because I used a bearer token, I'll use . Let's create our users, or in Apache APISIX terms, consumers:
consumers:
- username: alice
plugins:
key-auth:
key: alice
- username: betty
plugins:
key-auth:
key: betty
- username: bob
plugins:
key-auth:
key: bob
- username: charlie
plugins:
key-auth:
key: charlie
routes:
- uri: /finance/salary*
upstream:
type: roundrobin
nodes:
"boot:8080": 1
plugins:
key-auth:
header: Authorization #1
proxy-rewrite:
headers:
set:
X-Account: $consumer_name #2
key-auth
and the Authorization
headerX-Account
HTTP header for the upstream
APISIX guarantees that requests that reach the Spring Boot app are authenticated. The code only needs to call the OPA service and follow the decision. We can entirely remove Spring Security and replace it with a simple filter:
bean {
val repo = ref<EmployeeRepository>()
router {
val props = ref<AppProperties>()
val opaWebClient = WebClient.create(props.opaEndpoint)
filter { req, next -> validateOpa(opaWebClient, req, next) }
GET("/finance/salary/{user_name}") {
// ...
}
}
}
internal fun validateOpa(
opaWebClient: WebClient,
req: ServerRequest,
next: (ServerRequest) -> ServerResponse
): ServerResponse {
val httpReq = req.servletRequest()
val account = httpReq.getHeader("X-Account") //1
val path = httpReq.servletPath.split('/').filter { it.isNotBlank() }
val decision = opaWebClient.post() //2
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(OpaInput(DataInput(account, path)))
.exchangeToMono { it.bodyToMono(DecisionOutput::class.java) }
.block() ?: DecisionOutput(ResultOutput(false))
return if (decision.result.allow) next(req)
else ServerResponse.status(HttpStatus.UNAUTHORIZED).build()
}
To go further:
Originally published at on February 26th, 2023