Skip to content

Commit

Permalink
Merge pull request #1227 from zalando/gh-1096-add-bearer-support
Browse files Browse the repository at this point in the history
Add a `Bearer` security schema support
  • Loading branch information
tkrop committed May 27, 2021
2 parents afe9e4a + 68418ef commit 0a8ddb5
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.media.Schema
import io.swagger.v3.oas.models.parameters.Parameter
import io.swagger.v3.oas.models.responses.ApiResponse
import io.swagger.v3.oas.models.security.SecurityScheme
import java.util.Objects

data class HeaderElement(
Expand Down Expand Up @@ -118,6 +119,28 @@ fun OpenAPI.getAllParameters(): Map<String, Parameter> = this.components?.parame
it?.readOperations().orEmpty().flatMap { it?.parameters.orEmpty().mapNotNull { it.name to it } }
}

fun OpenAPI.getAllSecuritySchemes(): Map<String, SecurityScheme> = this.components?.securitySchemes.orEmpty()

/**
* Checks if the SecurityScheme is a Bearer security scheme
*/
fun SecurityScheme.isBearer(): Boolean = this.scheme == "bearer" && this.type == SecurityScheme.Type.HTTP

/**
* Checks if the SecurityScheme is an OAuth2 security scheme
*/
fun SecurityScheme.isOAuth2(): Boolean = this.type == SecurityScheme.Type.OAUTH2

fun SecurityScheme.allFlows() = listOfNotNull(
this.flows?.implicit,
this.flows?.password,
this.flows?.clientCredentials,
this.flows?.authorizationCode
)

fun SecurityScheme.allScopes(): List<String> =
this.allFlows().flatMap { flow -> flow.scopes?.keys.orEmpty() }.toSet().filterNotNull()

/**
* Calculates custom hash to avoid calling the hash of the parent schema.
* E.g. io.swagger.v3.oas.models.media.ArraySchema#hashCode() calls super#hashCode()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package org.zalando.zally.ruleset.zalando

import com.typesafe.config.Config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.Operation
import io.swagger.v3.oas.models.PathItem
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.zalando.zally.core.util.allFlows
import org.zalando.zally.core.util.allScopes
import org.zalando.zally.core.util.getAllSecuritySchemes
import org.zalando.zally.core.util.isOAuth2
import org.zalando.zally.rule.api.Check
import org.zalando.zally.rule.api.Context
import org.zalando.zally.rule.api.Rule
import org.zalando.zally.rule.api.Severity
import org.zalando.zally.rule.api.Violation
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.PathItem
import io.swagger.v3.oas.models.security.SecurityScheme
import java.util.SortedSet

@Rule(
ruleSet = ZalandoRuleSet::class,
Expand All @@ -33,7 +38,8 @@ class SecureAllEndpointsWithScopesRule(rulesConfig: Config) {
@Check(severity = Severity.MUST)
fun checkDefinedScopeFormats(context: Context): List<Violation> =
context.api.components?.securitySchemes?.values.orEmpty()
.filter { it.type == SecurityScheme.Type.OAUTH2 }
.filter { it.isOAuth2() }
.filterNotNull()
.flatMap { it.allFlows() }
.flatMap { flow ->
flow.scopes.orEmpty().keys.filterNot { scope ->
Expand All @@ -46,56 +52,67 @@ class SecureAllEndpointsWithScopesRule(rulesConfig: Config) {

@Check(severity = Severity.MUST)
fun checkOperationsAreScoped(context: Context): List<Violation> {
val defined = defined(context.api)
val securitySchemes = context.api.getAllSecuritySchemes()

// val definedScopes = definedScopes(context.api)
return context.validateOperations(pathFilter = this::pathFilter) { (_, op) ->
op?.let {
val requested = requested(context.api, op, defined)
val undefined = undefined(requested, defined)
when {
requested.isEmpty() -> context.violations("Endpoint not secured by OAuth2 scope(s)", op.security
?: op)
undefined.isNotEmpty() -> context.violations(
"Endpoint secured by undefined OAuth2 scope(s): ${undefined.joinToString()}", op.security
?: op
)
else -> emptyList()

val definedOpSecurityRequirements = definedSecurityRequirements(op, context.api)

if (definedOpSecurityRequirements.isEmpty()) {
context.violations("Endpoint is not secured by scope(s)", op.security ?: op)
} else {
definedOpSecurityRequirements.flatMap {
it.map { (opSchemeName, opScopes) ->
val matchingScheme = securitySchemes[opSchemeName]
when {
matchingScheme == null -> {
context.violation("Security scheme $opSchemeName not found", op)
}
matchingScheme.isOAuth2() -> {
validateOAuth2Schema(context, op, opScopes, matchingScheme, opSchemeName)
}
else -> null
} // Scopes are only used with OAuth 2 and OpenID Connect
}
}
}
}.orEmpty()
}
}

private fun pathFilter(entry: Map.Entry<String, PathItem?>): Boolean =
pathWhitelist.none { it.containsMatchIn(entry.key) }

private fun SecurityScheme?.allFlows() = listOfNotNull(
this?.flows?.implicit,
this?.flows?.password,
this?.flows?.clientCredentials,
this?.flows?.authorizationCode
)

private fun defined(api: OpenAPI): Map<String, Set<String>> = api.components?.securitySchemes.orEmpty()
.filterValues { scheme -> scheme.type == SecurityScheme.Type.OAUTH2 }
.mapValues { it.value.allFlows().flatMap { it.scopes?.keys.orEmpty() }.toSet() }

private fun requested(
api: OpenAPI,
op: io.swagger.v3.oas.models.Operation?,
defined: Map<String, Set<String>>
): List<Pair<String, String>> = (op?.security ?: api.security ?: emptyList())
.flatMap { requirement ->
requirement
.filterKeys { name -> defined.containsKey(name) }
.flatMap { (name, scopes) -> scopes.map { name to it } }
private fun definedSecurityRequirements(operation: Operation, api: OpenAPI): List<SecurityRequirement> {
val operationSecurity = operation.security.orEmpty()
if (operationSecurity.isEmpty()) {
return api.security.orEmpty()
}
return operationSecurity
}

private fun undefined(
requested: List<Pair<String, String>>,
defined: Map<String, Set<String>>
): SortedSet<String> = requested
.filterNot { (name, scope) ->
defined[name].orEmpty().contains(scope)
private fun validateOAuth2Schema(
context: Context,
op: Operation,
requestedScopes: List<String?>,
definedScheme: SecurityScheme,
schemeName: String
): Violation? {
if (requestedScopes.isEmpty()) {
return context.violation(
"Endpoint is not secured by OAuth2 scope(s)", op.security ?: op
)
}
.map { "${it.first}:${it.second}" }
.toSortedSet()
val definedScopes = definedScheme.allScopes()
val undefined =
requestedScopes.filterNotNull().filterNot { sc -> definedScopes.contains(sc) }
return if (undefined.isNotEmpty()) {
context.violation(
"Endpoint is secured by undefined OAuth2 scope(s): $schemeName:${undefined.joinToString()}",
op.security ?: op
)
} else null
}

private fun pathFilter(entry: Map.Entry<String, PathItem?>): Boolean =
pathWhitelist.none { it.containsMatchIn(entry.key) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.zalando.zally.ruleset.zalando.util.getConfigFromContent
import org.zalando.zally.test.ZallyAssertions
import org.intellij.lang.annotations.Language
import org.junit.Test
import org.zalando.zally.test.ZallyAssertions.assertThat

/**
* Tests for SecureAllEndpointsWithScopesRule
Expand Down Expand Up @@ -140,8 +141,8 @@ class SecureAllEndpointsWithScopesRuleTest {

val violations = rule.checkOperationsAreScoped(context)

ZallyAssertions.assertThat(violations)
.descriptionsEqualTo("Endpoint not secured by OAuth2 scope(s)")
assertThat(violations)
.descriptionsEqualTo("Endpoint is not secured by scope(s)")
.pointersEqualTo("/paths/~1things/get")
}

Expand Down Expand Up @@ -200,8 +201,8 @@ class SecureAllEndpointsWithScopesRuleTest {

val violations = rule.checkOperationsAreScoped(context)

ZallyAssertions.assertThat(violations)
.descriptionsEqualTo("Endpoint secured by undefined OAuth2 scope(s): oauth2:undefined-scope")
assertThat(violations)
.descriptionsEqualTo("Endpoint is secured by undefined OAuth2 scope(s): oauth2:undefined-scope")
.pointersEqualTo("/paths/~1things/get/security")
}

Expand Down Expand Up @@ -231,7 +232,7 @@ class SecureAllEndpointsWithScopesRuleTest {

val violations = rule.checkOperationsAreScoped(context)

ZallyAssertions.assertThat(violations).isEmpty()
assertThat(violations).isEmpty()
}

@Test
Expand Down Expand Up @@ -291,8 +292,138 @@ class SecureAllEndpointsWithScopesRuleTest {

val violations = rule.checkOperationsAreScoped(context)

ZallyAssertions.assertThat(violations)
.descriptionsEqualTo("Endpoint not secured by OAuth2 scope(s)")
assertThat(violations)
.descriptionsEqualTo("Endpoint is not secured by scope(s)")
.pointersEqualTo("/paths/~1things/get")
}

@Test
fun `Rule supports Bearer security scheme`() {
@Language("YAML")
val yaml = """
openapi: 3.0.1
paths:
'/things':
get:
responses:
200:
description: OK
security:
- BearerAuth: ['scope.execute']
'/other-things':
get:
responses:
200:
description: OK
security:
- BearerAuth: []
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
""".trimIndent()

val context = DefaultContextFactory().getOpenApiContext(yaml)

val violations = rule.checkOperationsAreScoped(context)
assertThat(violations).isEmpty()
}

@Test
fun `Unsecured path is detected using Bearer security scheme`() {
@Language("YAML")
val yaml = """
openapi: 3.0.1
paths:
'/unsecured-path':
get:
responses:
200:
description: OK
'/other-things':
get:
responses:
200:
description: OK
security:
- BearerAuth: []
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
""".trimIndent()

val context = DefaultContextFactory().getOpenApiContext(yaml)

val violations = rule.checkOperationsAreScoped(context)
assertThat(violations).hasSize(1)
}

@Test
fun `Rule supports Bearer global security scheme`() {
@Language("YAML")
val yaml = """
openapi: 3.0.1
security:
- BearerAuth: ['scope.execute']
paths:
'/things':
get:
responses:
200:
description: OK
'/other-things':
get:
responses:
200:
description: OK
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
""".trimIndent()

val context = DefaultContextFactory().getOpenApiContext(yaml)

val violations = rule.checkOperationsAreScoped(context)
assertThat(violations).isEmpty()
}

@Test
fun `Security scheme names match`() {
@Language("YAML")
val yaml = """
openapi: 3.0.1
security:
- AnotherBearerAuth: ['scope.execute']
paths:
'/things':
get:
responses:
200:
description: OK
'/other-things':
get:
responses:
200:
description: OK
security:
- BearerAuth: []
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
""".trimIndent()

val context = DefaultContextFactory().getOpenApiContext(yaml)

val violations = rule.checkOperationsAreScoped(context)
assertThat(violations).hasSize(1)
}
}

0 comments on commit 0a8ddb5

Please sign in to comment.