Skip to content

Commit ef24d2d

Browse files
committed
[THREESCALE-2236] Add methods option on Keycloak policy.
This commits add the `methods` option on the Keycloak policy. That allow users to define a new policy from any jwt claim, resource and method. To be backwards compatible 'ANY' method is in place, and if methods are not defined this global method will be used and all will work as normal. Example policy: ``` "policy_chain": [ { "name": "apicast.policy.keycloak_role_check", "configuration": { "scopes": [ { "realm_roles": [ { "name": "director" } ], "resource": "/confidential", "methods": ["POST"] } ], "type": "blacklist" } }, { "name": "apicast.policy.apicast" } ] ``` Signed-off-by: Eloy Coto <[email protected]>
1 parent f64716c commit ef24d2d

File tree

6 files changed

+297
-13
lines changed

6 files changed

+297
-13
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1212
- "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)
1313
- 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)
1414
- 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)
15-
1615
- 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)
16+
- Added the option to manage access based on method on Keycloak Policy. [THREESCALE-2236](https://issues.jboss.org/browse/THREESCALE-2236) [PR #1039](https://github.com/3scale/APIcast/pull/1039)
1717

1818
### Fixed
1919

gateway/src/apicast/policy/keycloak_role_check/README.md

+14
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,17 @@
104104
]
105105
}
106106
```
107+
108+
- When you want to allow those who have the realm role `role1` to access `/resource1` and only methods GET and POST.
109+
110+
```json
111+
{
112+
"scopes": [
113+
{
114+
"realm_roles": [ { "name": "role1" } ],
115+
"resource": "/resource1",
116+
"methods": ["GET", "POST"]
117+
}
118+
]
119+
}
120+
```

gateway/src/apicast/policy/keycloak_role_check/apicast-policy.json

+20
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@
7777
"resource_type": {
7878
"description": "How to evaluate 'resource'",
7979
"$ref": "#/definitions/value_type"
80+
},
81+
"methods": {
82+
"description": "Allowed methods",
83+
"type": "array",
84+
"default": ["ANY"],
85+
"items": {
86+
"type": "string",
87+
"enum": [
88+
"ANY",
89+
"GET",
90+
"HEAD",
91+
"POST",
92+
"PUT",
93+
"DELETE",
94+
"PATCH",
95+
"OPTIONS",
96+
"TRACE",
97+
"CONNECT"
98+
]
99+
}
80100
}
81101
}
82102
}

gateway/src/apicast/policy/keycloak_role_check/keycloak_role_check.lua

+28-12
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ local default_type = 'plain'
6060

6161
local new = _M.new
6262

63+
local any_method = 'ANY'
64+
6365
local function create_template(value, value_type)
6466
return TemplateString.new(value, value_type or default_type)
6567
end
@@ -87,7 +89,12 @@ local function build_templates(scopes)
8789
scope.resource_template_string = create_template(
8890
scope.resource, scope.resource_type)
8991

92+
if not scope.methods then
93+
scope.methods = { any_method }
94+
end
95+
9096
end
97+
9198
end
9299

93100
function _M.new(config)
@@ -154,29 +161,38 @@ end
154161

155162
local function scope_check(scopes, context)
156163
local uri = ngx.var.uri
164+
local request_method = ngx.req.get_method()
157165

158166
if not context.jwt then
159167
return false
160168
end
161169

162170
for _, scope in ipairs(scopes) do
171+
for _, method in ipairs(scope.methods) do
172+
-- make a matched method just in case that `ANY` method is defined and
173+
-- the mapping rules does not match, the matched_method need to be
174+
-- cleared in the next interaction to trigger with the correct method.
175+
local matched_method = request_method
176+
if method == any_method then
177+
matched_method = any_method
178+
end
163179

164-
local resource = scope.resource_template_string:render(context)
180+
local resource = scope.resource_template_string:render(context)
165181

166-
local mapping_rule = MappingRule.from_proxy_rule({
167-
http_method = 'ANY',
168-
pattern = resource,
169-
querystring_parameters = {},
170-
-- the name of the metric is irrelevant
171-
metric_system_name = 'hits'
172-
})
182+
local mapping_rule = MappingRule.from_proxy_rule({
183+
http_method = method,
184+
pattern = resource,
185+
querystring_parameters = {},
186+
-- the name of the metric is irrelevant
187+
metric_system_name = 'hits'
188+
})
173189

174-
if mapping_rule:matches('ANY', uri) then
175-
if match_realm_roles(scope, context) and match_client_roles(scope, context) then
176-
return true
190+
if mapping_rule:matches(matched_method, uri) then
191+
if match_realm_roles(scope, context) and match_client_roles(scope, context) then
192+
return true
193+
end
177194
end
178195
end
179-
180196
end
181197

182198
return false

spec/policy/keycloak_role_check/keycloak_role_check_spec.lua

+77
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ describe('Keycloak Role check policy', function()
77
ngx.header = {}
88
stub(ngx, 'print')
99

10+
stub(ngx.req, 'get_method', function() return 'GET' end)
11+
stub(ngx_variable, 'available_context', function(context) return context end)
1012
-- avoid stubbing all the ngx.var.* and ngx.req.* in the available context
1113
stub(ngx_variable, 'available_context', function(context) return context end)
1214
end)
@@ -572,6 +574,81 @@ describe('Keycloak Role check policy', function()
572574
assert.not_same(ngx.status, 403)
573575
end)
574576
end)
577+
578+
describe("validate method roles", function()
579+
580+
before_each(function()
581+
ngx.var = {
582+
uri = '/foo'
583+
}
584+
end)
585+
586+
local context = {
587+
jwt = {
588+
realm_access = {
589+
roles = { "known_role" }
590+
},
591+
resource_access = {
592+
known_client = {
593+
roles = { "role_of_known_client" }
594+
}
595+
}
596+
},
597+
service = {
598+
auth_failed_status = 403,
599+
error_auth_failed = "auth failed"
600+
}
601+
}
602+
603+
it("when jwt role matches and one method also match", function()
604+
local role_check_policy = KeycloakRoleCheckPolicy.new({
605+
scopes = {{
606+
client_roles = { { name = "role_of_known_client", client = "known_client" } },
607+
resource = "/foo",
608+
methods = {"GET", "POST", "PUT"}
609+
}}})
610+
role_check_policy:access(context)
611+
assert.not_same(ngx.status, 403)
612+
end)
613+
614+
it("when jwt role matches and one method also match with blacklist mode", function()
615+
local role_check_policy = KeycloakRoleCheckPolicy.new({
616+
scopes = {{
617+
client_roles = { { name = "role_of_known_client", client = "known_client" } },
618+
resource = "/foo",
619+
methods = {"GET", "POST", "PUT"}
620+
}},
621+
type = "blacklist"})
622+
623+
role_check_policy:access(context)
624+
assert.same(ngx.status, 403)
625+
end)
626+
627+
it("when jwt role matches and no methods matches", function()
628+
local role_check_policy = KeycloakRoleCheckPolicy.new({
629+
scopes = {{
630+
client_roles = { { name = "role_of_known_client", client = "known_client" } },
631+
resource = "/foo",
632+
methods = {"POST", "PUT"}
633+
}}})
634+
role_check_policy:access(context)
635+
assert.same(ngx.status, 403)
636+
end)
637+
638+
it("when jwt role matches and no methods matches with blacklist mode", function()
639+
local role_check_policy = KeycloakRoleCheckPolicy.new({
640+
scopes = {{
641+
client_roles = { { name = "role_of_known_client", client = "known_client" } },
642+
resource = "/foo",
643+
methods = {"POST", "PUT"}
644+
}},
645+
type = "blacklist"})
646+
role_check_policy:access(context)
647+
assert.not_same(ngx.status, 403)
648+
end)
649+
650+
end)
651+
575652
end)
576653
end)
577654
end)

t/apicast-policy-keycloak-role-check.t

+157
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,160 @@ yay, api backend
393393
--- no_error_log
394394
[error]
395395
oauth failed with
396+
397+
398+
=== TEST6: Role check with allow methods
399+
The client which has the appropriate role accesses the resource with only one method allowed
400+
--- backend
401+
location /transactions/oauth_authrep.xml {
402+
content_by_lua_block {
403+
ngx.exit(200)
404+
}
405+
}
406+
407+
--- configuration
408+
{
409+
"oidc": [
410+
{
411+
"issuer": "https://example.com/auth/realms/apicast",
412+
"config": { "id_token_signing_alg_values_supported": [ "RS256" ] },
413+
"keys": { "somekid": { "pem": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALClz96cDQ965ENYMfZzG+Acu25lpx2K\nNpAALBQ+catCA59us7+uLY5rjQR6SOgZpCz5PJiKNAdRPDJMXSmXqM0CAwEAAQ==\n-----END PUBLIC KEY-----" } }
414+
}
415+
],
416+
"services": [
417+
{
418+
"id": 42,
419+
"backend_version": "oauth",
420+
"backend_authentication_type": "service_token",
421+
"backend_authentication_value": "token-value",
422+
"proxy": {
423+
"authentication_method": "oidc",
424+
"oidc_issuer_endpoint": "https://example.com/auth/realms/apicast",
425+
"api_backend": "http://test:$TEST_NGINX_SERVER_PORT/",
426+
"proxy_rules": [
427+
{ "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 }
428+
],
429+
"policy_chain": [
430+
{
431+
"name": "apicast.policy.keycloak_role_check",
432+
"configuration": {
433+
"scopes": [
434+
{
435+
"realm_roles": [ { "name": "director" } ],
436+
"resource": "/confidential",
437+
"methods": ["GET"]
438+
}
439+
]
440+
}
441+
},
442+
{ "name": "apicast.policy.apicast" }
443+
]
444+
}
445+
}
446+
]
447+
}
448+
--- upstream
449+
location /confidential {
450+
content_by_lua_block {
451+
ngx.say('yay, api backend');
452+
}
453+
}
454+
--- pipelined_requests eval
455+
["GET /confidential", "POST /confidential"]
456+
--- more_headers eval
457+
[::authorization_bearer_jwt('audience', {
458+
realm_access => {
459+
roles => [ 'director' ]
460+
}
461+
}, 'somekid'),
462+
::authorization_bearer_jwt('audience', {
463+
realm_access => {
464+
roles => [ 'director' ]
465+
}
466+
}, 'somekid')]
467+
--- response_body eval
468+
["yay, api backend\n", "Authentication failed"]
469+
--- error_code eval
470+
[200, 403]
471+
--- no_error_log
472+
[error]
473+
oauth failed with
474+
475+
476+
=== TEST7: Role check with allow methods and blacklist mode
477+
Check an allowed role with the blacklisted mode with methods
478+
--- backend
479+
location /transactions/oauth_authrep.xml {
480+
content_by_lua_block {
481+
ngx.exit(200)
482+
}
483+
}
484+
485+
--- configuration
486+
{
487+
"oidc": [
488+
{
489+
"issuer": "https://example.com/auth/realms/apicast",
490+
"config": { "id_token_signing_alg_values_supported": [ "RS256" ] },
491+
"keys": { "somekid": { "pem": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALClz96cDQ965ENYMfZzG+Acu25lpx2K\nNpAALBQ+catCA59us7+uLY5rjQR6SOgZpCz5PJiKNAdRPDJMXSmXqM0CAwEAAQ==\n-----END PUBLIC KEY-----" } }
492+
}
493+
],
494+
"services": [
495+
{
496+
"id": 42,
497+
"backend_version": "oauth",
498+
"backend_authentication_type": "service_token",
499+
"backend_authentication_value": "token-value",
500+
"proxy": {
501+
"authentication_method": "oidc",
502+
"oidc_issuer_endpoint": "https://example.com/auth/realms/apicast",
503+
"api_backend": "http://test:$TEST_NGINX_SERVER_PORT/",
504+
"proxy_rules": [
505+
{ "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 }
506+
],
507+
"policy_chain": [
508+
{
509+
"name": "apicast.policy.keycloak_role_check",
510+
"configuration": {
511+
"scopes": [
512+
{
513+
"realm_roles": [ { "name": "director" } ],
514+
"resource": "/confidential",
515+
"methods": ["POST"]
516+
}
517+
],
518+
"type": "blacklist"
519+
}
520+
},
521+
{ "name": "apicast.policy.apicast" }
522+
]
523+
}
524+
}
525+
]
526+
}
527+
--- upstream
528+
location /confidential {
529+
content_by_lua_block {
530+
ngx.say('yay, api backend');
531+
}
532+
}
533+
--- pipelined_requests eval
534+
["GET /confidential", "POST /confidential"]
535+
--- more_headers eval
536+
[::authorization_bearer_jwt('audience', {
537+
realm_access => {
538+
roles => [ 'director' ]
539+
}
540+
}, 'somekid'),
541+
::authorization_bearer_jwt('audience', {
542+
realm_access => {
543+
roles => [ 'director' ]
544+
}
545+
}, 'somekid')]
546+
--- response_body eval
547+
["yay, api backend\n", "Authentication failed"]
548+
--- error_code eval
549+
[200, 403]
550+
--- no_error_log
551+
[error]
552+
oauth failed with

0 commit comments

Comments
 (0)