diff --git a/CHANGELOG.md b/CHANGELOG.md index 681f701aa..33474040d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - "Upstream Connection" policy. It allows to configure several options for the connections to the upstream [PR #1025](https://github.com/3scale/APIcast/pull/1025), [THREESCALE-2166](https://issues.jboss.org/browse/THREESCALE-2166) - Enable APICAST_EXTENDED_METRICS environment variable to provide additional details [PR #1024](https://github.com/3scale/APIcast/pull/1024) [THREESCALE-2150](https://issues.jboss.org/browse/THREESCALE-2150) - Add the option to obtain client_id from any JWT claim [THREESCALE-2264](https://issues.jboss.org/browse/THREESCALE-2264) [PR #1034](https://github.com/3scale/APIcast/pull/1034) - - Added `APICAST_PATH_ROUTING_ONLY` variable that allows to perform path-based routing without falling back to the default host-based routing [PR #1035](https://github.com/3scale/APIcast/pull/1035), [THREESCALE-1150](https://issues.jboss.org/browse/THREESCALE-1150) +- Added the option to manage access based on method on Keycloak Policy. [THREESCALE-2236](https://issues.jboss.org/browse/THREESCALE-2236) [PR #](https://github.com/3scale/APIcast/pull/) ### Fixed diff --git a/gateway/src/apicast/policy/keycloak_role_check/README.md b/gateway/src/apicast/policy/keycloak_role_check/README.md index 0b14ace57..40cf6ab8a 100644 --- a/gateway/src/apicast/policy/keycloak_role_check/README.md +++ b/gateway/src/apicast/policy/keycloak_role_check/README.md @@ -104,3 +104,17 @@ ] } ``` + +- When you want to allow those who have the realm role `role1` to access `/resource1` and only methods GET and POST. + + ```json + { + "scopes": [ + { + "realm_roles": [ { "name": "role1" } ], + "resource": "/resource1", + "methods": ["GET", "POST"] + } + ] + } + ``` diff --git a/gateway/src/apicast/policy/keycloak_role_check/apicast-policy.json b/gateway/src/apicast/policy/keycloak_role_check/apicast-policy.json index e655ab5c1..780b49d21 100644 --- a/gateway/src/apicast/policy/keycloak_role_check/apicast-policy.json +++ b/gateway/src/apicast/policy/keycloak_role_check/apicast-policy.json @@ -77,6 +77,26 @@ "resource_type": { "description": "How to evaluate 'resource'", "$ref": "#/definitions/value_type" + }, + "methods": { + "description": "Allowed methods", + "type": "array", + "default": ["ANY"], + "items": { + "type": "string", + "enum": [ + "ANY", + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "PATCH", + "OPTIONS", + "TRACE", + "CONNECT" + ] + } } } } diff --git a/gateway/src/apicast/policy/keycloak_role_check/keycloak_role_check.lua b/gateway/src/apicast/policy/keycloak_role_check/keycloak_role_check.lua index 3fb7e71c7..086860aef 100644 --- a/gateway/src/apicast/policy/keycloak_role_check/keycloak_role_check.lua +++ b/gateway/src/apicast/policy/keycloak_role_check/keycloak_role_check.lua @@ -60,6 +60,8 @@ local default_type = 'plain' local new = _M.new +local any_method = 'ANY' + local function create_template(value, value_type) return TemplateString.new(value, value_type or default_type) end @@ -87,7 +89,12 @@ local function build_templates(scopes) scope.resource_template_string = create_template( scope.resource, scope.resource_type) + if not scope.methods then + scope.methods = { any_method } + end + end + end function _M.new(config) @@ -154,29 +161,38 @@ end local function scope_check(scopes, context) local uri = ngx.var.uri + local request_method = ngx.req.get_method() if not context.jwt then return false end for _, scope in ipairs(scopes) do + for _, method in ipairs(scope.methods) do + -- make a matched method just in case that `ANY` method is defined and + -- the mapping rules does not match, the matched_method need to be + -- cleared in the next interaction to trigger with the correct method. + local matched_method = request_method + if method == any_method then + matched_method = any_method + end - local resource = scope.resource_template_string:render(context) + local resource = scope.resource_template_string:render(context) - local mapping_rule = MappingRule.from_proxy_rule({ - http_method = 'ANY', - pattern = resource, - querystring_parameters = {}, - -- the name of the metric is irrelevant - metric_system_name = 'hits' - }) + local mapping_rule = MappingRule.from_proxy_rule({ + http_method = method, + pattern = resource, + querystring_parameters = {}, + -- the name of the metric is irrelevant + metric_system_name = 'hits' + }) - if mapping_rule:matches('ANY', uri) then - if match_realm_roles(scope, context) and match_client_roles(scope, context) then - return true + if mapping_rule:matches(matched_method, uri) then + if match_realm_roles(scope, context) and match_client_roles(scope, context) then + return true + end end end - end return false diff --git a/spec/policy/keycloak_role_check/keycloak_role_check_spec.lua b/spec/policy/keycloak_role_check/keycloak_role_check_spec.lua index 4f496ada6..050ba67dc 100644 --- a/spec/policy/keycloak_role_check/keycloak_role_check_spec.lua +++ b/spec/policy/keycloak_role_check/keycloak_role_check_spec.lua @@ -7,6 +7,8 @@ describe('Keycloak Role check policy', function() ngx.header = {} stub(ngx, 'print') + stub(ngx.req, 'get_method', function() return 'GET' end) + stub(ngx_variable, 'available_context', function(context) return context end) -- avoid stubbing all the ngx.var.* and ngx.req.* in the available context stub(ngx_variable, 'available_context', function(context) return context end) end) @@ -572,6 +574,81 @@ describe('Keycloak Role check policy', function() assert.not_same(ngx.status, 403) end) end) + + describe("validate method roles", function() + + before_each(function() + ngx.var = { + uri = '/foo' + } + end) + + local context = { + jwt = { + realm_access = { + roles = { "known_role" } + }, + resource_access = { + known_client = { + roles = { "role_of_known_client" } + } + } + }, + service = { + auth_failed_status = 403, + error_auth_failed = "auth failed" + } + } + + it("when jwt role matches and one method also match", function() + local role_check_policy = KeycloakRoleCheckPolicy.new({ + scopes = {{ + client_roles = { { name = "role_of_known_client", client = "known_client" } }, + resource = "/foo", + methods = {"GET", "POST", "PUT"} + }}}) + role_check_policy:access(context) + assert.not_same(ngx.status, 403) + end) + + it("when jwt role matches and one method also match with blacklist mode", function() + local role_check_policy = KeycloakRoleCheckPolicy.new({ + scopes = {{ + client_roles = { { name = "role_of_known_client", client = "known_client" } }, + resource = "/foo", + methods = {"GET", "POST", "PUT"} + }}, + type = "blacklist"}) + + role_check_policy:access(context) + assert.same(ngx.status, 403) + end) + + it("when jwt role matches and no methods matches", function() + local role_check_policy = KeycloakRoleCheckPolicy.new({ + scopes = {{ + client_roles = { { name = "role_of_known_client", client = "known_client" } }, + resource = "/foo", + methods = {"POST", "PUT"} + }}}) + role_check_policy:access(context) + assert.same(ngx.status, 403) + end) + + it("when jwt role matches and no methods matches with blacklist mode", function() + local role_check_policy = KeycloakRoleCheckPolicy.new({ + scopes = {{ + client_roles = { { name = "role_of_known_client", client = "known_client" } }, + resource = "/foo", + methods = {"POST", "PUT"} + }}, + type = "blacklist"}) + role_check_policy:access(context) + assert.not_same(ngx.status, 403) + end) + + end) + end) end) end) diff --git a/t/apicast-policy-keycloak-role-check.t b/t/apicast-policy-keycloak-role-check.t index 8772cfd9c..be840299b 100644 --- a/t/apicast-policy-keycloak-role-check.t +++ b/t/apicast-policy-keycloak-role-check.t @@ -393,3 +393,160 @@ yay, api backend --- no_error_log [error] oauth failed with + + +=== TEST6: Role check with allow methods +The client which has the appropriate role accesses the resource with only one method allowed +--- backend + location /transactions/oauth_authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } + +--- configuration +{ + "oidc": [ + { + "issuer": "https://example.com/auth/realms/apicast", + "config": { "id_token_signing_alg_values_supported": [ "RS256" ] }, + "keys": { "somekid": { "pem": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALClz96cDQ965ENYMfZzG+Acu25lpx2K\nNpAALBQ+catCA59us7+uLY5rjQR6SOgZpCz5PJiKNAdRPDJMXSmXqM0CAwEAAQ==\n-----END PUBLIC KEY-----" } } + } + ], + "services": [ + { + "id": 42, + "backend_version": "oauth", + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "authentication_method": "oidc", + "oidc_issuer_endpoint": "https://example.com/auth/realms/apicast", + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.keycloak_role_check", + "configuration": { + "scopes": [ + { + "realm_roles": [ { "name": "director" } ], + "resource": "/confidential", + "methods": ["GET"] + } + ] + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location /confidential { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- pipelined_requests eval +["GET /confidential", "POST /confidential"] +--- more_headers eval +[::authorization_bearer_jwt('audience', { + realm_access => { + roles => [ 'director' ] + } +}, 'somekid'), +::authorization_bearer_jwt('audience', { + realm_access => { + roles => [ 'director' ] + } +}, 'somekid')] +--- response_body eval +["yay, api backend\n", "Authentication failed"] +--- error_code eval +[200, 403] +--- no_error_log +[error] +oauth failed with + + +=== TEST7: Role check with allow methods and blacklist mode +Check an allowed role with the blacklisted mode with methods +--- backend + location /transactions/oauth_authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } + +--- configuration +{ + "oidc": [ + { + "issuer": "https://example.com/auth/realms/apicast", + "config": { "id_token_signing_alg_values_supported": [ "RS256" ] }, + "keys": { "somekid": { "pem": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALClz96cDQ965ENYMfZzG+Acu25lpx2K\nNpAALBQ+catCA59us7+uLY5rjQR6SOgZpCz5PJiKNAdRPDJMXSmXqM0CAwEAAQ==\n-----END PUBLIC KEY-----" } } + } + ], + "services": [ + { + "id": 42, + "backend_version": "oauth", + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "authentication_method": "oidc", + "oidc_issuer_endpoint": "https://example.com/auth/realms/apicast", + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.keycloak_role_check", + "configuration": { + "scopes": [ + { + "realm_roles": [ { "name": "director" } ], + "resource": "/confidential", + "methods": ["POST"] + } + ], + "type": "blacklist" + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location /confidential { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- pipelined_requests eval +["GET /confidential", "POST /confidential"] +--- more_headers eval +[::authorization_bearer_jwt('audience', { + realm_access => { + roles => [ 'director' ] + } +}, 'somekid'), +::authorization_bearer_jwt('audience', { + realm_access => { + roles => [ 'director' ] + } +}, 'somekid')] +--- response_body eval +["yay, api backend\n", "Authentication failed"] +--- error_code eval +[200, 403] +--- no_error_log +[error] +oauth failed with